énorme refactoring de PMOcontrol step 1
This commit is contained in:
50
.vscode/settings.json
vendored
50
.vscode/settings.json
vendored
@@ -1,15 +1,37 @@
|
|||||||
{
|
{
|
||||||
"makefile.configureOnOpen": false,
|
"makefile.configureOnOpen": false,
|
||||||
"git.enabled": false,
|
"git.enabled": false,
|
||||||
"claude-code.environmentVariables": [
|
"claude-code.environmentVariables": [],
|
||||||
|
|
||||||
],
|
// Exclusions de fichiers/dossiers inutiles pour Rust Analyzer
|
||||||
// Exclusions via VS Code
|
"files.exclude": {
|
||||||
"files.exclude": {
|
"target": true,
|
||||||
"target": true,
|
"**/target": true,
|
||||||
"**/target": true,
|
"node_modules": true,
|
||||||
"node_modules": true
|
"tests/huge_benchmarks": true,
|
||||||
},
|
"examples": true,
|
||||||
"rust-analyzer.procMacro.enable": true,
|
"docs": true
|
||||||
"rust-analyzer.numThreads": 4
|
},
|
||||||
}
|
// Rust Analyzer settings
|
||||||
|
"rust-analyzer.rustupPath": "/Users/coissac/.cargo/bin/rustup",
|
||||||
|
"rust-analyzer.cargoPath": "/Users/coissac/.cargo/bin/cargo",
|
||||||
|
"rust-analyzer.rustcSource": "discover",
|
||||||
|
"rust-analyzer.procMacro.enable": true,
|
||||||
|
"rust-analyzer.numThreads": 4,
|
||||||
|
"rust-analyzer.cargo.loadOutDirsFromCheck": false,
|
||||||
|
"rust-analyzer.checkOnSave.enable": true,
|
||||||
|
"rust-analyzer.checkOnSave.command": "clippy",
|
||||||
|
"rust-analyzer.checkOnSave.extraArgs": ["--all-features"],
|
||||||
|
"rust-analyzer.exclude": [
|
||||||
|
"target",
|
||||||
|
"tests/huge_benchmarks",
|
||||||
|
"examples",
|
||||||
|
"docs"
|
||||||
|
],
|
||||||
|
"rust-analyzer.server.extraEnv": {
|
||||||
|
"RA_LARGE_PROJECT": "1"
|
||||||
|
},
|
||||||
|
"rust-analyzer.runnables.command": null,
|
||||||
|
"rust-analyzer.server.path": null,
|
||||||
|
"rust-analyzer.cargo.allTargets": true,
|
||||||
|
}
|
||||||
|
|||||||
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -306,6 +306,17 @@ dependencies = [
|
|||||||
"rustix 1.1.2",
|
"rustix 1.1.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-recursion"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-signal"
|
name = "async-signal"
|
||||||
version = "0.2.13"
|
version = "0.2.13"
|
||||||
@@ -3999,6 +4010,8 @@ name = "pmoupnp"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-recursion",
|
||||||
|
"async-trait",
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use pmometadata::{MetadataError, MetadataResult, TrackMetadata};
|
use pmometadata::{MetadataError, MetadataResult, TrackMetadata};
|
||||||
use serde_json::{Number, Value};
|
use serde_json::{Number, Value};
|
||||||
@@ -394,7 +394,7 @@ impl TrackMetadata for AudioCacheTrackMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn touch(&mut self) -> MetadataResult<()> {
|
async fn touch(&mut self) -> MetadataResult<()> {
|
||||||
self.write_timestamp(SystemTime::now())?;
|
self.write_timestamp(Instant::now())?;
|
||||||
Ok(Some(()))
|
Ok(Some(()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::thread;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
|
ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo,
|
||||||
PlaybackState, PlaybackStatus, TransportControl, VolumeControl,
|
PlaybackState, PlaybackStatus, TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,10 +38,10 @@ fn main() -> io::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let arylic_renderers: Vec<MusicRenderer> = renderers
|
let arylic_renderers: Vec<MusicRendererBackend> = renderers
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|info| info.capabilities.has_arylic_tcp)
|
.filter(|info| info.capabilities.has_arylic_tcp)
|
||||||
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry))
|
.filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), ®istry))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if arylic_renderers.is_empty() {
|
if arylic_renderers.is_empty() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::thread;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use pmocontrol::RendererProtocol;
|
use pmocontrol::RendererProtocol;
|
||||||
use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo};
|
use pmocontrol::{ControlPoint, DeviceRegistryRead, UpnpMediaServer, RendererInfo};
|
||||||
|
|
||||||
fn main() -> std::io::Result<()> {
|
fn main() -> std::io::Result<()> {
|
||||||
// Un tout petit logging optionnel
|
// Un tout petit logging optionnel
|
||||||
@@ -21,7 +21,7 @@ fn main() -> std::io::Result<()> {
|
|||||||
let reg = reg.read().expect("registry poisoned");
|
let reg = reg.read().expect("registry poisoned");
|
||||||
|
|
||||||
let renderers: Vec<RendererInfo> = reg.list_renderers();
|
let renderers: Vec<RendererInfo> = reg.list_renderers();
|
||||||
let servers: Vec<MediaServerInfo> = reg.list_servers();
|
let servers: Vec<UpnpMediaServer> = reg.list_servers();
|
||||||
|
|
||||||
println!("=====================");
|
println!("=====================");
|
||||||
println!("Renderers detected : {}", renderers.len());
|
println!("Renderers detected : {}", renderers.len());
|
||||||
@@ -29,7 +29,7 @@ fn main() -> std::io::Result<()> {
|
|||||||
let proto = match r.protocol {
|
let proto = match r.protocol {
|
||||||
RendererProtocol::UpnpAvOnly => "UPnP AV",
|
RendererProtocol::UpnpAvOnly => "UPnP AV",
|
||||||
RendererProtocol::OpenHomeOnly => "OpenHome",
|
RendererProtocol::OpenHomeOnly => "OpenHome",
|
||||||
RendererProtocol::Hybrid => "Hybrid",
|
RendererProtocol::OpenHomeHybrid => "Hybrid",
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use crossterm::terminal::{
|
|||||||
};
|
};
|
||||||
use pmocontrol::model::TrackMetadata;
|
use pmocontrol::model::TrackMetadata;
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
|
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
||||||
MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||||
RendererEvent, RendererInfo, TransportControl, VolumeControl,
|
RendererEvent, RendererInfo, TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
@@ -102,7 +102,7 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
let servers = {
|
let servers = {
|
||||||
let reg = registry.read().expect("registry poisoned");
|
let reg = registry.read().expect("registry poisoned");
|
||||||
let list: Vec<MediaServerInfo> = reg
|
let list: Vec<UpnpMediaServer> = reg
|
||||||
.list_servers()
|
.list_servers()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| s.has_content_directory && s.content_directory_control_url.is_some())
|
.filter(|s| s.has_content_directory && s.content_directory_control_url.is_some())
|
||||||
@@ -129,10 +129,10 @@ struct App {
|
|||||||
renderers: Vec<RendererInfo>,
|
renderers: Vec<RendererInfo>,
|
||||||
renderer_index: usize,
|
renderer_index: usize,
|
||||||
renderer_info: Option<RendererInfo>,
|
renderer_info: Option<RendererInfo>,
|
||||||
servers: Vec<MediaServerInfo>,
|
servers: Vec<UpnpMediaServer>,
|
||||||
server_index: usize,
|
server_index: usize,
|
||||||
server_info: Option<MediaServerInfo>,
|
server_info: Option<UpnpMediaServer>,
|
||||||
music_server: Option<MusicServer>,
|
music_server: Option<UpnpMediaServer>,
|
||||||
browser: Option<BrowserState>,
|
browser: Option<BrowserState>,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
ui_state: UiState,
|
ui_state: UiState,
|
||||||
@@ -168,7 +168,7 @@ impl App {
|
|||||||
fn new(
|
fn new(
|
||||||
control_point: Arc<ControlPoint>,
|
control_point: Arc<ControlPoint>,
|
||||||
renderers: Vec<RendererInfo>,
|
renderers: Vec<RendererInfo>,
|
||||||
servers: Vec<MediaServerInfo>,
|
servers: Vec<UpnpMediaServer>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
control_point,
|
control_point,
|
||||||
@@ -192,7 +192,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderer_id(&self) -> Option<pmocontrol::model::RendererId> {
|
fn renderer_id(&self) -> Option<pmocontrol::model::ServiceId> {
|
||||||
self.renderer_info.as_ref().map(|info| info.id.clone())
|
self.renderer_info.as_ref().map(|info| info.id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +552,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
let info = self.servers[self.server_index].clone();
|
let info = self.servers[self.server_index].clone();
|
||||||
match MusicServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
match UpnpMediaServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||||
Ok(server) => {
|
Ok(server) => {
|
||||||
let entries = server.browse_root()?;
|
let entries = server.browse_root()?;
|
||||||
let browser = BrowserState::new(entries);
|
let browser = BrowserState::new(entries);
|
||||||
@@ -947,7 +947,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::RendererId) {
|
fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::ServiceId) {
|
||||||
if let Ok((queue, current_index)) = self.control_point.get_full_queue_snapshot(renderer_id)
|
if let Ok((queue, current_index)) = self.control_point.get_full_queue_snapshot(renderer_id)
|
||||||
{
|
{
|
||||||
self.queue_snapshot = queue;
|
self.queue_snapshot = queue;
|
||||||
@@ -967,7 +967,7 @@ impl App {
|
|||||||
|
|
||||||
fn peek_next_queue_item(
|
fn peek_next_queue_item(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &pmocontrol::model::RendererId,
|
renderer_id: &pmocontrol::model::ServiceId,
|
||||||
) -> Option<PlaybackItem> {
|
) -> Option<PlaybackItem> {
|
||||||
self.control_point
|
self.control_point
|
||||||
.get_queue_snapshot(renderer_id)
|
.get_queue_snapshot(renderer_id)
|
||||||
@@ -975,7 +975,7 @@ impl App {
|
|||||||
.and_then(|queue| queue.into_iter().next())
|
.and_then(|queue| queue.into_iter().next())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_renderer(&self) -> Result<pmocontrol::MusicRenderer> {
|
fn get_renderer(&self) -> Result<pmocontrol::MusicRendererBackend> {
|
||||||
let renderer_id = self
|
let renderer_id = self
|
||||||
.renderer_id()
|
.renderer_id()
|
||||||
.ok_or_else(|| anyhow!("Renderer not selected"))?;
|
.ok_or_else(|| anyhow!("Renderer not selected"))?;
|
||||||
@@ -1230,7 +1230,7 @@ impl NavigationState {
|
|||||||
|
|
||||||
/// Collect playable items from MediaEntry list (including nested containers).
|
/// Collect playable items from MediaEntry list (including nested containers).
|
||||||
fn collect_playable_items(
|
fn collect_playable_items(
|
||||||
server: &MusicServer,
|
server: &UpnpMediaServer,
|
||||||
entries: &[MediaEntry],
|
entries: &[MediaEntry],
|
||||||
) -> Result<Vec<PlaybackItem>> {
|
) -> Result<Vec<PlaybackItem>> {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@@ -1255,7 +1255,7 @@ fn collect_playable_items(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert MediaEntry to PlaybackItem.
|
/// Convert MediaEntry to PlaybackItem.
|
||||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||||
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
||||||
let metadata = TrackMetadata {
|
let metadata = TrackMetadata {
|
||||||
title: Some(entry.title.clone()),
|
title: Some(entry.title.clone()),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::thread;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
|
ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo,
|
||||||
PlaybackState, PlaybackStatus, VolumeControl,
|
PlaybackState, PlaybackStatus, VolumeControl,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,10 +34,10 @@ fn main() -> io::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let linkplay_renderers: Vec<MusicRenderer> = renderers
|
let linkplay_renderers: Vec<MusicRendererBackend> = renderers
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|info| info.capabilities.has_linkplay_http)
|
.filter(|info| info.capabilities.has_linkplay_http)
|
||||||
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry))
|
.filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), ®istry))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if linkplay_renderers.is_empty() {
|
if linkplay_renderers.is_empty() {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use std::time::Duration;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use pmocontrol::model::TrackMetadata;
|
use pmocontrol::model::TrackMetadata;
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
|
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
||||||
MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||||
@@ -69,7 +69,7 @@ fn main() -> Result<()> {
|
|||||||
server_info.friendly_name, server_info.location, server_info.id.0
|
server_info.friendly_name, server_info.location, server_info.id.0
|
||||||
);
|
);
|
||||||
|
|
||||||
let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry)
|
let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), ®istry)
|
||||||
.expect("Selected renderer is not usable by MusicRenderer façade");
|
.expect("Selected renderer is not usable by MusicRenderer façade");
|
||||||
let supports_set_next = renderer_instance
|
let supports_set_next = renderer_instance
|
||||||
.as_upnp()
|
.as_upnp()
|
||||||
@@ -82,7 +82,7 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
let timeout = Duration::from_secs(config.timeout_secs);
|
let timeout = Duration::from_secs(config.timeout_secs);
|
||||||
let server =
|
let server =
|
||||||
MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
UpnpMediaServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
||||||
|
|
||||||
println!("Searching for a Live Playlist container in ContentDirectory...");
|
println!("Searching for a Live Playlist container in ContentDirectory...");
|
||||||
let live_playlist_container = find_live_playlist_container(&server)
|
let live_playlist_container = find_live_playlist_container(&server)
|
||||||
@@ -355,8 +355,8 @@ fn pick_renderer(renderers: Vec<RendererInfo>) -> Option<RendererInfo> {
|
|||||||
Some(selected)
|
Some(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pick_pmomusic_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo> {
|
fn pick_pmomusic_server(servers: Vec<UpnpMediaServer>) -> Option<UpnpMediaServer> {
|
||||||
let mut candidates: Vec<MediaServerInfo> = servers
|
let mut candidates: Vec<UpnpMediaServer> = servers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|info| info.has_content_directory)
|
.filter(|info| info.has_content_directory)
|
||||||
.filter(|info| info.content_directory_control_url.is_some())
|
.filter(|info| info.content_directory_control_url.is_some())
|
||||||
@@ -381,7 +381,7 @@ fn pick_pmomusic_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo
|
|||||||
Some(candidates.remove(0))
|
Some(candidates.remove(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_pmomusic_server(info: &MediaServerInfo) -> bool {
|
fn is_pmomusic_server(info: &UpnpMediaServer) -> bool {
|
||||||
let name = info.friendly_name.to_ascii_lowercase();
|
let name = info.friendly_name.to_ascii_lowercase();
|
||||||
let model = info.model_name.to_ascii_lowercase();
|
let model = info.model_name.to_ascii_lowercase();
|
||||||
let manufacturer = info.manufacturer.to_ascii_lowercase();
|
let manufacturer = info.manufacturer.to_ascii_lowercase();
|
||||||
@@ -395,7 +395,7 @@ fn is_pmomusic_renderer(info: &RendererInfo) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search for a container whose title contains "Live Playlist" using BFS.
|
/// Search for a container whose title contains "Live Playlist" using BFS.
|
||||||
fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntry>> {
|
fn find_live_playlist_container(server: &UpnpMediaServer) -> Result<Option<MediaEntry>> {
|
||||||
let root_entries = server
|
let root_entries = server
|
||||||
.browse_root()
|
.browse_root()
|
||||||
.context("Failed to browse ContentDirectory root")?;
|
.context("Failed to browse ContentDirectory root")?;
|
||||||
@@ -477,7 +477,7 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
|||||||
/// Collect playable items from a specific container.
|
/// Collect playable items from a specific container.
|
||||||
/// Retries with fewer items if the initial browse times out.
|
/// Retries with fewer items if the initial browse times out.
|
||||||
fn collect_playable_items_from_container(
|
fn collect_playable_items_from_container(
|
||||||
server: &MusicServer,
|
server: &UpnpMediaServer,
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
max_tracks: usize,
|
max_tracks: usize,
|
||||||
) -> Result<Vec<PlaybackItem>> {
|
) -> Result<Vec<PlaybackItem>> {
|
||||||
@@ -532,7 +532,7 @@ fn collect_playable_items_from_container(
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||||
// Skip live streams (we're looking for regular tracks in a live playlist)
|
// Skip live streams (we're looking for regular tracks in a live playlist)
|
||||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossbeam_channel::RecvTimeoutError;
|
use crossbeam_channel::RecvTimeoutError;
|
||||||
use pmocontrol::{ControlPoint, MediaServerEvent, MediaServerInfo, ServerId};
|
use pmocontrol::{ControlPoint, MediaServerEvent, UpnpMediaServer, ServerId};
|
||||||
|
|
||||||
const DISCOVERY_WAIT_SECS: u64 = 5;
|
const DISCOVERY_WAIT_SECS: u64 = 5;
|
||||||
const MONITOR_DURATION_SECS: u64 = 90;
|
const MONITOR_DURATION_SECS: u64 = 90;
|
||||||
@@ -33,7 +33,7 @@ fn main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cache: HashMap<ServerId, MediaServerInfo> = servers
|
let mut cache: HashMap<ServerId, UpnpMediaServer> = servers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|info| (info.id.clone(), info))
|
.map(|info| (info.id.clone(), info))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -64,7 +64,7 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
fn print_event(
|
fn print_event(
|
||||||
cp: &ControlPoint,
|
cp: &ControlPoint,
|
||||||
cache: &mut HashMap<ServerId, MediaServerInfo>,
|
cache: &mut HashMap<ServerId, UpnpMediaServer>,
|
||||||
event: &MediaServerEvent,
|
event: &MediaServerEvent,
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
@@ -101,7 +101,7 @@ fn print_event(
|
|||||||
|
|
||||||
fn describe_server(
|
fn describe_server(
|
||||||
cp: &ControlPoint,
|
cp: &ControlPoint,
|
||||||
cache: &mut HashMap<ServerId, MediaServerInfo>,
|
cache: &mut HashMap<ServerId, UpnpMediaServer>,
|
||||||
id: &ServerId,
|
id: &ServerId,
|
||||||
) -> String {
|
) -> String {
|
||||||
if let Some(info) = cache.get(id) {
|
if let Some(info) = cache.get(id) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRenderer,
|
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRendererBackend,
|
||||||
RendererInfo,
|
RendererInfo,
|
||||||
control_point::ControlPoint,
|
control_point::ControlPoint,
|
||||||
openhome_client::{
|
openhome_client::{
|
||||||
@@ -86,7 +86,7 @@ fn auto_discover_renderer() -> Result<RendererInfo> {
|
|||||||
let mut openhome_infos = Vec::new();
|
let mut openhome_infos = Vec::new();
|
||||||
|
|
||||||
for renderer in renderers {
|
for renderer in renderers {
|
||||||
if let MusicRenderer::OpenHome(oh) = renderer {
|
if let MusicRendererBackend::OpenHome(oh) = renderer {
|
||||||
if seen.insert(oh.id().0.clone()) {
|
if seen.insert(oh.id().0.clone()) {
|
||||||
openhome_infos.push(oh.info.clone());
|
openhome_infos.push(oh.info.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use std::time::Duration;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use pmocontrol::model::TrackMetadata;
|
use pmocontrol::model::TrackMetadata;
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
|
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
||||||
MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||||
@@ -72,7 +72,7 @@ fn main() -> Result<()> {
|
|||||||
server_info.friendly_name, server_info.location, server_info.id.0
|
server_info.friendly_name, server_info.location, server_info.id.0
|
||||||
);
|
);
|
||||||
|
|
||||||
let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry)
|
let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), ®istry)
|
||||||
.expect("Selected renderer is not usable by MusicRenderer façade");
|
.expect("Selected renderer is not usable by MusicRenderer façade");
|
||||||
let supports_set_next = renderer_instance
|
let supports_set_next = renderer_instance
|
||||||
.as_upnp()
|
.as_upnp()
|
||||||
@@ -85,7 +85,7 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
let timeout = Duration::from_secs(config.timeout_secs);
|
let timeout = Duration::from_secs(config.timeout_secs);
|
||||||
let server =
|
let server =
|
||||||
MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
UpnpMediaServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
||||||
|
|
||||||
let root_entries = server
|
let root_entries = server
|
||||||
.browse_root()
|
.browse_root()
|
||||||
@@ -322,8 +322,8 @@ fn pick_renderer(renderers: Vec<RendererInfo>) -> Option<RendererInfo> {
|
|||||||
Some(selected)
|
Some(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pick_media_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo> {
|
fn pick_media_server(servers: Vec<UpnpMediaServer>) -> Option<UpnpMediaServer> {
|
||||||
let mut candidates: Vec<MediaServerInfo> = servers
|
let mut candidates: Vec<UpnpMediaServer> = servers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|info| info.has_content_directory)
|
.filter(|info| info.has_content_directory)
|
||||||
.filter(|info| info.content_directory_control_url.is_some())
|
.filter(|info| info.content_directory_control_url.is_some())
|
||||||
@@ -346,7 +346,7 @@ fn pick_media_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_pmomusic_server(info: &MediaServerInfo) -> bool {
|
fn is_pmomusic_server(info: &UpnpMediaServer) -> bool {
|
||||||
let name = info.friendly_name.to_ascii_lowercase();
|
let name = info.friendly_name.to_ascii_lowercase();
|
||||||
let header = info.server_header.to_ascii_lowercase();
|
let header = info.server_header.to_ascii_lowercase();
|
||||||
name.contains("pmomusic") || header.contains("pmomusic")
|
name.contains("pmomusic") || header.contains("pmomusic")
|
||||||
@@ -359,7 +359,7 @@ fn is_pmomusic_renderer(info: &RendererInfo) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn collect_playable_items_with_binding(
|
fn collect_playable_items_with_binding(
|
||||||
server: &MusicServer,
|
server: &UpnpMediaServer,
|
||||||
entries: &[MediaEntry],
|
entries: &[MediaEntry],
|
||||||
max_tracks: usize,
|
max_tracks: usize,
|
||||||
) -> Result<(Vec<PlaybackItem>, Option<String>)> {
|
) -> Result<(Vec<PlaybackItem>, Option<String>)> {
|
||||||
@@ -423,7 +423,7 @@ fn collect_playable_items_with_binding(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn gather_items_from_entry(
|
fn gather_items_from_entry(
|
||||||
server: &MusicServer,
|
server: &UpnpMediaServer,
|
||||||
entry: &MediaEntry,
|
entry: &MediaEntry,
|
||||||
max_tracks: usize,
|
max_tracks: usize,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
@@ -464,7 +464,7 @@ fn gather_items_from_entry(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use pmocontrol::PlaybackPosition;
|
|||||||
use pmocontrol::model::RendererInfo;
|
use pmocontrol::model::RendererInfo;
|
||||||
use pmocontrol::openhome_client::{OhInfoClient, OhPlaylistClient, OhTimeClient};
|
use pmocontrol::openhome_client::{OhInfoClient, OhPlaylistClient, OhTimeClient};
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities,
|
ControlPoint, MusicRendererBackend, PlaybackState, PlaybackStatus, RendererCapabilities,
|
||||||
RendererProtocol, TransportControl, VolumeControl,
|
RendererProtocol, TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -37,7 +37,7 @@ fn main() -> Result<()> {
|
|||||||
thread::sleep(Duration::from_secs(5));
|
thread::sleep(Duration::from_secs(5));
|
||||||
|
|
||||||
// 2. Snapshot of logical music renderers
|
// 2. Snapshot of logical music renderers
|
||||||
let mut renderers: Vec<MusicRenderer> = cp.list_music_renderers();
|
let mut renderers: Vec<MusicRendererBackend> = cp.list_music_renderers();
|
||||||
|
|
||||||
// Filter out the in-dev PMOMusic renderer (if present)
|
// Filter out the in-dev PMOMusic renderer (if present)
|
||||||
renderers.retain(|r| {
|
renderers.retain(|r| {
|
||||||
@@ -276,20 +276,20 @@ fn print_openhome_details(prefix: &str, info: &RendererInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_backend(prefix: &str, renderer: &MusicRenderer) {
|
fn print_backend(prefix: &str, renderer: &MusicRendererBackend) {
|
||||||
let backend = match renderer {
|
let backend = match renderer {
|
||||||
MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
|
MusicRendererBackend::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
|
||||||
MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
|
MusicRendererBackend::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
|
||||||
MusicRenderer::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)",
|
MusicRendererBackend::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)",
|
||||||
MusicRenderer::HybridUpnpArylic { .. } => {
|
MusicRendererBackend::HybridUpnpArylic { .. } => {
|
||||||
"Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)"
|
"Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)"
|
||||||
}
|
}
|
||||||
MusicRenderer::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
|
MusicRendererBackend::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
|
||||||
};
|
};
|
||||||
println!("{prefix}Backend : {backend}");
|
println!("{prefix}Backend : {backend}");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
|
fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<()> {
|
||||||
println!("\n[{label}]");
|
println!("\n[{label}]");
|
||||||
|
|
||||||
if let Ok(state) = renderer.playback_state() {
|
if let Ok(state) = renderer.playback_state() {
|
||||||
@@ -344,7 +344,7 @@ fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, interval_secs: u64) {
|
fn progress_monitor(renderer: &MusicRendererBackend, label: &str, iterations: usize, interval_secs: u64) {
|
||||||
println!(
|
println!(
|
||||||
"\n[{label}] polling playback state/position {} times (every {} s)...",
|
"\n[{label}] polling playback state/position {} times (every {} s)...",
|
||||||
iterations, interval_secs
|
iterations, interval_secs
|
||||||
@@ -370,7 +370,7 @@ fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn volume_demo(renderer: &MusicRenderer) -> Result<()> {
|
fn volume_demo(renderer: &MusicRendererBackend) -> Result<()> {
|
||||||
// Try to get current volume
|
// Try to get current volume
|
||||||
let original = renderer.volume()?;
|
let original = renderer.volume()?;
|
||||||
println!(" Current music volume : {}", original);
|
println!(" Current music volume : {}", original);
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ use std::sync::{Mutex, OnceLock};
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::anyhow;
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::DeviceIdentity;
|
||||||
use crate::capabilities::{
|
use crate::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||||
VolumeControl,
|
VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::linkplay::{extract_linkplay_host, parse_flat_json};
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::{RendererId, RendererInfo};
|
use crate::linkplay_renderer::{extract_linkplay_host, parse_flat_json};
|
||||||
|
use crate::model::RendererInfo;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
// Garde global pour respecter le délai de 200ms entre commandes
|
// Garde global pour respecter le délai de 200ms entre commandes
|
||||||
@@ -76,7 +77,7 @@ pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool {
|
|||||||
detected
|
detected
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_detect_tcp(host: &str, timeout: Duration) -> Result<()> {
|
fn try_detect_tcp(host: &str, timeout: Duration) -> Result<(), ControlPointError> {
|
||||||
let payload = send_command_required(
|
let payload = send_command_required(
|
||||||
host,
|
host,
|
||||||
ARYLIC_TCP_PORT,
|
ARYLIC_TCP_PORT,
|
||||||
@@ -88,57 +89,47 @@ fn try_detect_tcp(host: &str, timeout: Duration) -> Result<()> {
|
|||||||
if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") {
|
if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!(
|
Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"Unexpected INF response from {}: {}",
|
"Unexpected INF response from {}: {}",
|
||||||
host,
|
host,
|
||||||
payload
|
payload
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backend speaking the Arylic TCP control protocol (port 8899).
|
/// Backend speaking the Arylic TCP control protocol (port 8899).
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ArylicTcpRenderer {
|
pub struct ArylicTcpRenderer {
|
||||||
pub info: RendererInfo,
|
|
||||||
host: String,
|
host: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArylicTcpRenderer {
|
impl ArylicTcpRenderer {
|
||||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
pub fn from_renderer_info(info: RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
let host = extract_linkplay_host(&info.location)
|
let host = extract_linkplay_host(info.location())
|
||||||
.ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?;
|
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Renderer {} has no valid LOCATION host", info.udn())))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
info,
|
|
||||||
host,
|
host,
|
||||||
port: ARYLIC_TCP_PORT,
|
port: ARYLIC_TCP_PORT,
|
||||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> &RendererId {
|
fn send_required(&self, cmd: &str, expected: &[&str]) -> Result<String, ControlPointError> {
|
||||||
&self.info.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn friendly_name(&self) -> &str {
|
|
||||||
&self.info.friendly_name
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_required(&self, cmd: &str, expected: &[&str]) -> Result<String> {
|
|
||||||
send_command_required(&self.host, self.port, self.timeout, cmd, expected)
|
send_command_required(&self.host, self.port, self.timeout, cmd, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result<Option<String>> {
|
fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result<Option<String>, ControlPointError> {
|
||||||
send_command_optional(&self.host, self.port, self.timeout, cmd, expected)
|
send_command_optional(&self.host, self.port, self.timeout, cmd, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_no_response(&self, cmd: &str) -> Result<()> {
|
fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> {
|
||||||
send_command_no_response(&self.host, self.port, self.timeout, cmd)
|
send_command_no_response(&self.host, self.port, self.timeout, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_playback_info(&self) -> Result<ArylicPlaybackInfo> {
|
fn fetch_playback_info(&self) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
||||||
let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?;
|
let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?;
|
||||||
match parse_playback_info(&payload) {
|
match parse_playback_info(&payload) {
|
||||||
Ok(info) => Ok(info),
|
Ok(info) => Ok(info),
|
||||||
@@ -156,59 +147,60 @@ impl ArylicTcpRenderer {
|
|||||||
format!("MCU+VOL+{:03}", value.min(100))
|
format!("MCU+VOL+{:03}", value.min(100))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_volume_payload(payload: &str) -> Result<u16> {
|
fn parse_volume_payload(payload: &str) -> Result<u16, ControlPointError> {
|
||||||
let data = payload
|
let data = payload
|
||||||
.strip_prefix("AXX+VOL+")
|
.strip_prefix("AXX+VOL+")
|
||||||
.ok_or_else(|| anyhow!("Unexpected volume response: {}", payload))?;
|
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload)))?;
|
||||||
let value: u16 = data
|
let value: u16 = data
|
||||||
.trim()
|
.trim()
|
||||||
.parse()
|
.parse()
|
||||||
.with_context(|| format!("Invalid volume value: {}", data))?;
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data)))?;
|
||||||
Ok(value.min(100))
|
Ok(value.min(100))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_mute_payload(payload: &str) -> Result<bool> {
|
fn parse_mute_payload(payload: &str) -> Result<bool, ControlPointError> {
|
||||||
let data = payload
|
let data = payload
|
||||||
.strip_prefix("AXX+MUT+")
|
.strip_prefix("AXX+MUT+")
|
||||||
.ok_or_else(|| anyhow!("Unexpected mute response: {}", payload))?;
|
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload)))?;
|
||||||
match data.trim() {
|
match data.trim() {
|
||||||
"000" | "0" => Ok(false),
|
"000" | "0" => Ok(false),
|
||||||
"001" | "1" => Ok(true),
|
"001" | "1" => Ok(true),
|
||||||
other => Err(anyhow!("Invalid mute value: {}", other)),
|
other => Err(ControlPointError::ArilycTcpError(format!("Invalid mute value: {}", other))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportControl for ArylicTcpRenderer {
|
impl TransportControl for ArylicTcpRenderer {
|
||||||
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<()> {
|
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||||
Err(anyhow!(
|
Err(ControlPointError::upnp_operation_not_supported(
|
||||||
"Arylic TCP backend does not support direct URL loading. Use UPnP AVTransport SetAVTransportURI instead."
|
"Arylic TCP backend does not support direct URL loading.",
|
||||||
|
"ArylicTcpRenderer",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play(&self) -> Result<()> {
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
self.send_no_response("MCU+PLY-PLA")
|
self.send_no_response("MCU+PLY-PLA")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pause(&self) -> Result<()> {
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?;
|
let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&self) -> Result<()> {
|
fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
self.send_no_response("MCU+PLY-STP")
|
self.send_no_response("MCU+PLY-STP")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
let _ = parse_hhmmss(hhmmss)?;
|
let _ = parse_hhmmss(hhmmss)?;
|
||||||
Err(anyhow!(
|
Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"Arylic TCP seek_rel_time is not implemented yet for this device."
|
"Arylic TCP seek_rel_time is not implemented yet for this device."
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VolumeControl for ArylicTcpRenderer {
|
impl VolumeControl for ArylicTcpRenderer {
|
||||||
fn volume(&self) -> Result<u16> {
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
if let Ok(info) = self.fetch_playback_info() {
|
if let Ok(info) = self.fetch_playback_info() {
|
||||||
if let Some(vol) = info.volume {
|
if let Some(vol) = info.volume {
|
||||||
return Ok(vol);
|
return Ok(vol);
|
||||||
@@ -222,13 +214,13 @@ impl VolumeControl for ArylicTcpRenderer {
|
|||||||
Self::parse_volume_payload(&payload)
|
Self::parse_volume_payload(&payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&self, v: u16) -> Result<()> {
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||||
let command = Self::format_volume_command(v);
|
let command = Self::format_volume_command(v);
|
||||||
let _ = self.send_optional(&command, &["AXX+VOL+"])?;
|
let _ = self.send_optional(&command, &["AXX+VOL+"])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool> {
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
if let Ok(info) = self.fetch_playback_info() {
|
if let Ok(info) = self.fetch_playback_info() {
|
||||||
if let Some(mute) = info.mute {
|
if let Some(mute) = info.mute {
|
||||||
return Ok(mute);
|
return Ok(mute);
|
||||||
@@ -242,7 +234,7 @@ impl VolumeControl for ArylicTcpRenderer {
|
|||||||
Self::parse_mute_payload(&payload)
|
Self::parse_mute_payload(&payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_mute(&self, m: bool) -> Result<()> {
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||||
let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" };
|
let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" };
|
||||||
let payload = self.send_required(command, &["AXX+MUT+"])?;
|
let payload = self.send_required(command, &["AXX+MUT+"])?;
|
||||||
let _ = Self::parse_mute_payload(&payload)?;
|
let _ = Self::parse_mute_payload(&payload)?;
|
||||||
@@ -251,14 +243,14 @@ impl VolumeControl for ArylicTcpRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackStatus for ArylicTcpRenderer {
|
impl PlaybackStatus for ArylicTcpRenderer {
|
||||||
fn playback_state(&self) -> Result<PlaybackState> {
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
let info = self.fetch_playback_info()?;
|
let info = self.fetch_playback_info()?;
|
||||||
Ok(info.playback_state())
|
Ok(info.playback_state())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackPosition for ArylicTcpRenderer {
|
impl PlaybackPosition for ArylicTcpRenderer {
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
let info = self.fetch_playback_info()?;
|
let info = self.fetch_playback_info()?;
|
||||||
Ok(info.position_info())
|
Ok(info.position_info())
|
||||||
}
|
}
|
||||||
@@ -307,10 +299,10 @@ impl ArylicPlaybackInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo> {
|
fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
||||||
let json_blob = payload
|
let json_blob = payload
|
||||||
.strip_prefix("AXX+PLY+INF")
|
.strip_prefix("AXX+PLY+INF")
|
||||||
.ok_or_else(|| anyhow!("Unexpected playback info prefix: {}", payload))?;
|
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload)))?;
|
||||||
|
|
||||||
let json_blob = json_blob.trim_end_matches('&').trim();
|
let json_blob = json_blob.trim_end_matches('&').trim();
|
||||||
let map = parse_flat_json(json_blob)?;
|
let map = parse_flat_json(json_blob)?;
|
||||||
@@ -318,7 +310,7 @@ fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo> {
|
|||||||
let status_raw = map
|
let status_raw = map
|
||||||
.get("status")
|
.get("status")
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| anyhow!("Playback info missing `status` field"))?;
|
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `status` field")))?;
|
||||||
|
|
||||||
let curpos_ms = parse_u64_field(&map, "curpos")?;
|
let curpos_ms = parse_u64_field(&map, "curpos")?;
|
||||||
let totlen_ms = parse_u64_field(&map, "totlen")?;
|
let totlen_ms = parse_u64_field(&map, "totlen")?;
|
||||||
@@ -375,15 +367,15 @@ fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64> {
|
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64, ControlPointError> {
|
||||||
let raw = map
|
let raw = map
|
||||||
.get(key)
|
.get(key)
|
||||||
.ok_or_else(|| anyhow!("Playback info missing `{}` field", key))?;
|
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `{}` field", key)))?;
|
||||||
raw.parse::<u64>()
|
raw.parse::<u64>()
|
||||||
.with_context(|| format!("Invalid `{}` value: {}", key, raw))
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid `{}` value: {}", key, raw)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream> {
|
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, ControlPointError> {
|
||||||
if let Ok(mut last_time) = last_command_time().lock() {
|
if let Ok(mut last_time) = last_command_time().lock() {
|
||||||
let elapsed = last_time.elapsed();
|
let elapsed = last_time.elapsed();
|
||||||
if elapsed < Duration::from_millis(200) {
|
if elapsed < Duration::from_millis(200) {
|
||||||
@@ -406,14 +398,24 @@ fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream> {
|
|||||||
let mut last_err = None;
|
let mut last_err = None;
|
||||||
for addr in address
|
for addr in address
|
||||||
.to_socket_addrs()
|
.to_socket_addrs()
|
||||||
.with_context(|| format!("Failed to resolve {}:{}", host, port))?
|
.with_context(|| format!("Failed to resolve {}:{}", host, port))
|
||||||
|
.map_err(|_| {
|
||||||
|
ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port))
|
||||||
|
})?
|
||||||
{
|
{
|
||||||
match TcpStream::connect_timeout(&addr, timeout) {
|
match TcpStream::connect_timeout(&addr, timeout) {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
stream
|
stream
|
||||||
.set_read_timeout(Some(timeout))
|
.set_read_timeout(Some(timeout))
|
||||||
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
||||||
.with_context(|| format!("Failed to set socket timeouts for {}", address))?;
|
.with_context(|| format!("Failed to set socket timeouts for {}", address))
|
||||||
|
.map_err(|err| {
|
||||||
|
ControlPointError::ArilycTcpError(format!(
|
||||||
|
"Failed to set socket timeouts for {}",
|
||||||
|
address
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
return Ok(stream);
|
return Ok(stream);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -423,13 +425,14 @@ fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match last_err {
|
match last_err {
|
||||||
Some((addr, err)) => Err(anyhow!(
|
Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"Failed to connect to {} via {}: {}",
|
"Failed to connect to {} via {}: {}",
|
||||||
host,
|
host, addr, err
|
||||||
addr,
|
))),
|
||||||
err
|
None => Err(ControlPointError::ArilycTcpError(format!(
|
||||||
)),
|
"No socket addresses resolved for {}",
|
||||||
None => Err(anyhow!("No socket addresses resolved for {}", address)),
|
address
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,26 +450,31 @@ fn encode_packet(payload: &str) -> Vec<u8> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_packet(stream: &mut TcpStream) -> Result<String> {
|
fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
|
||||||
let mut header = [0u8; 4];
|
let mut header = [0u8; 4];
|
||||||
stream.read_exact(&mut header)?;
|
stream.read_exact(&mut header)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
if header != PACKET_HEADER {
|
if header != PACKET_HEADER {
|
||||||
return Err(anyhow!("Invalid Arylic packet header: {:x?}", header));
|
return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut len_buf = [0u8; 4];
|
let mut len_buf = [0u8; 4];
|
||||||
stream.read_exact(&mut len_buf)?;
|
stream.read_exact(&mut len_buf)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
let len = u32::from_le_bytes(len_buf) as usize;
|
let len = u32::from_le_bytes(len_buf) as usize;
|
||||||
|
|
||||||
let mut checksum_buf = [0u8; 4];
|
let mut checksum_buf = [0u8; 4];
|
||||||
stream.read_exact(&mut checksum_buf)?;
|
stream.read_exact(&mut checksum_buf)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
||||||
|
|
||||||
let mut reserved = [0u8; 8];
|
let mut reserved = [0u8; 8];
|
||||||
stream.read_exact(&mut reserved)?;
|
stream.read_exact(&mut reserved)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
|
|
||||||
let mut payload = vec![0u8; len];
|
let mut payload = vec![0u8; len];
|
||||||
stream.read_exact(&mut payload)?;
|
stream.read_exact(&mut payload)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
|
|
||||||
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||||
if actual_checksum != expected_checksum {
|
if actual_checksum != expected_checksum {
|
||||||
@@ -476,7 +484,8 @@ fn read_packet(stream: &mut TcpStream) -> Result<String> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(String::from_utf8(payload)?)
|
Ok(String::from_utf8(payload)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_hms(secs: u64) -> String {
|
fn format_hms(secs: u64) -> String {
|
||||||
@@ -486,30 +495,30 @@ fn format_hms(secs: u64) -> String {
|
|||||||
format!("{:02}:{:02}:{:02}", h, m, s)
|
format!("{:02}:{:02}:{:02}", h, m, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_hhmmss(value: &str) -> Result<u64> {
|
fn parse_hhmmss(value: &str) -> Result<u64, ControlPointError> {
|
||||||
let parts: Vec<_> = value.split(':').collect();
|
let parts: Vec<_> = value.split(':').collect();
|
||||||
if parts.len() != 3 {
|
if parts.len() != 3 {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"Invalid time format `{}`. Expected HH:MM:SS.",
|
"Invalid time format `{}`. Expected HH:MM:SS.",
|
||||||
value
|
value
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let hours: u64 = parts[0]
|
let hours: u64 = parts[0]
|
||||||
.parse()
|
.parse()
|
||||||
.with_context(|| format!("Invalid hour component in {}", value))?;
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid hour component in {}", value)))?;
|
||||||
let minutes: u64 = parts[1]
|
let minutes: u64 = parts[1]
|
||||||
.parse()
|
.parse()
|
||||||
.with_context(|| format!("Invalid minute component in {}", value))?;
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid minute component in {}", value)))?;
|
||||||
let seconds: u64 = parts[2]
|
let seconds: u64 = parts[2]
|
||||||
.parse()
|
.parse()
|
||||||
.with_context(|| format!("Invalid second component in {}", value))?;
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid second component in {}", value)))?;
|
||||||
|
|
||||||
if minutes > 59 || seconds > 59 {
|
if minutes > 59 || seconds > 59 {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.",
|
"Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.",
|
||||||
value
|
value
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(hours * 3600 + minutes * 60 + seconds)
|
Ok(hours * 3600 + minutes * 60 + seconds)
|
||||||
@@ -521,21 +530,24 @@ fn send_command_with_mode(
|
|||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
payload: &str,
|
payload: &str,
|
||||||
mode: ResponseMode<'_>,
|
mode: ResponseMode<'_>,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>, ControlPointError> {
|
||||||
let mut stream = connect(host, port, timeout)?;
|
let mut stream = connect(host, port, timeout)?;
|
||||||
let packet = encode_packet(payload);
|
let packet = encode_packet(payload);
|
||||||
|
|
||||||
stream.write_all(&packet).with_context(|| {
|
stream.write_all(&packet).map_err(|_| {
|
||||||
|
ControlPointError::ArilycTcpError(
|
||||||
format!(
|
format!(
|
||||||
"Failed to write Arylic TCP packet for {}: {}",
|
"Failed to write Arylic TCP packet for {}: {}",
|
||||||
host, payload
|
host, payload
|
||||||
)
|
))
|
||||||
})?;
|
})?;
|
||||||
stream.flush().with_context(|| {
|
|
||||||
|
stream.flush().map_err(|_| {
|
||||||
|
ControlPointError::ArilycTcpError(
|
||||||
format!(
|
format!(
|
||||||
"Failed to flush Arylic TCP stream for {} (command {})",
|
"Failed to flush Arylic TCP stream for {} (command {})",
|
||||||
host, payload
|
host, payload
|
||||||
)
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
@@ -572,11 +584,11 @@ fn send_command_with_mode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!(
|
Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"No expected response for optional command {} on {}",
|
"No expected response for optional command {} on {}",
|
||||||
payload,
|
payload,
|
||||||
host
|
host
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,17 +598,17 @@ fn read_expected_response(
|
|||||||
host: &str,
|
host: &str,
|
||||||
payload: &str,
|
payload: &str,
|
||||||
expected: &[&str],
|
expected: &[&str],
|
||||||
) -> Result<String> {
|
) -> Result<String, ControlPointError> {
|
||||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||||
let response = match read_packet(stream) {
|
let response = match read_packet(stream) {
|
||||||
Ok(resp) => resp,
|
Ok(resp) => resp,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"Failed to read Arylic TCP response for {} (command {}): {}",
|
"Failed to read Arylic TCP response for {} (command {}): {}",
|
||||||
host,
|
host,
|
||||||
payload,
|
payload,
|
||||||
err
|
err
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if expected.iter().any(|prefix| response.starts_with(prefix)) {
|
if expected.iter().any(|prefix| response.starts_with(prefix)) {
|
||||||
@@ -609,11 +621,11 @@ fn read_expected_response(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!(
|
Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"No expected response for command {} on {}",
|
"No expected response for command {} on {}",
|
||||||
payload,
|
payload,
|
||||||
host
|
host
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_command_required(
|
fn send_command_required(
|
||||||
@@ -622,7 +634,7 @@ fn send_command_required(
|
|||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
payload: &str,
|
payload: &str,
|
||||||
expected: &[&str],
|
expected: &[&str],
|
||||||
) -> Result<String> {
|
) -> Result<String, ControlPointError> {
|
||||||
match send_command_with_mode(
|
match send_command_with_mode(
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
@@ -631,10 +643,10 @@ fn send_command_required(
|
|||||||
ResponseMode::Required(expected),
|
ResponseMode::Required(expected),
|
||||||
)? {
|
)? {
|
||||||
Some(s) => Ok(s),
|
Some(s) => Ok(s),
|
||||||
None => Err(anyhow!(
|
None => Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"Arylic TCP: no response payload for required command {}",
|
"Arylic TCP: no response payload for required command {}",
|
||||||
payload
|
payload
|
||||||
)),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +656,7 @@ fn send_command_optional(
|
|||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
payload: &str,
|
payload: &str,
|
||||||
expected: &[&str],
|
expected: &[&str],
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>, ControlPointError> {
|
||||||
send_command_with_mode(
|
send_command_with_mode(
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
@@ -654,6 +666,6 @@ fn send_command_optional(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<()> {
|
fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> {
|
||||||
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
|
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action, invoke_upnp_action_with_timeout};
|
use crate::{
|
||||||
|
errors::ControlPointError,
|
||||||
|
soap_client::{
|
||||||
|
extract_child_text, handle_action_response, invoke_upnp_action, invoke_upnp_action_with_timeout
|
||||||
|
},
|
||||||
|
};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use pmoupnp::soap::SoapEnvelope;
|
use pmoupnp::soap::SoapEnvelope;
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
@@ -31,7 +36,7 @@ impl AvTransportClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AVTransport:1 — GetTransportInfo
|
/// AVTransport:1 — GetTransportInfo
|
||||||
pub fn get_transport_info(&self, instance_id: u32) -> Result<TransportInfo> {
|
pub fn get_transport_info(&self, instance_id: u32) -> Result<TransportInfo, ControlPointError> {
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let args = [("InstanceID", instance_id_str.as_str())];
|
let args = [("InstanceID", instance_id_str.as_str())];
|
||||||
|
|
||||||
@@ -43,16 +48,16 @@ impl AvTransportClient {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
if !call_result.status.is_success() {
|
if !call_result.status.is_success() {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetTransportInfo failed with HTTP status {}",
|
"GetTransportInfo failed with HTTP status {}",
|
||||||
call_result.status
|
call_result.status
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result
|
||||||
.envelope
|
.envelope
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetTransportInfo response"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetTransportInfo response")))?;
|
||||||
|
|
||||||
parse_transport_info(envelope)
|
parse_transport_info(envelope)
|
||||||
}
|
}
|
||||||
@@ -64,7 +69,7 @@ impl AvTransportClient {
|
|||||||
///
|
///
|
||||||
/// - `uri` : CurrentURI
|
/// - `uri` : CurrentURI
|
||||||
/// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide)
|
/// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide)
|
||||||
pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
let args = [
|
let args = [
|
||||||
("InstanceID", "0"),
|
("InstanceID", "0"),
|
||||||
("CurrentURI", uri),
|
("CurrentURI", uri),
|
||||||
@@ -82,7 +87,7 @@ impl AvTransportClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AVTransport:1 — Play
|
/// AVTransport:1 — Play
|
||||||
pub fn play(&self, instance_id: u32, speed: &str) -> Result<()> {
|
pub fn play(&self, instance_id: u32, speed: &str) -> Result<(), ControlPointError> {
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let args = [("InstanceID", instance_id_str.as_str()), ("Speed", speed)];
|
let args = [("InstanceID", instance_id_str.as_str()), ("Speed", speed)];
|
||||||
|
|
||||||
@@ -92,7 +97,7 @@ impl AvTransportClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AVTransport:1 — Pause
|
/// AVTransport:1 — Pause
|
||||||
pub fn pause(&self, instance_id: u32) -> Result<()> {
|
pub fn pause(&self, instance_id: u32) -> Result<(), ControlPointError> {
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let args = [("InstanceID", instance_id_str.as_str())];
|
let args = [("InstanceID", instance_id_str.as_str())];
|
||||||
|
|
||||||
@@ -103,7 +108,7 @@ impl AvTransportClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AVTransport:1 — Stop
|
/// AVTransport:1 — Stop
|
||||||
pub fn stop(&self, instance_id: u32) -> Result<()> {
|
pub fn stop(&self, instance_id: u32) -> Result<(), ControlPointError> {
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let args = [("InstanceID", instance_id_str.as_str())];
|
let args = [("InstanceID", instance_id_str.as_str())];
|
||||||
|
|
||||||
@@ -113,7 +118,12 @@ impl AvTransportClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AVTransport:1 — Seek
|
/// AVTransport:1 — Seek
|
||||||
pub fn seek(&self, instance_id: u32, unit: &str, target: &str) -> Result<()> {
|
pub fn seek(
|
||||||
|
&self,
|
||||||
|
instance_id: u32,
|
||||||
|
unit: &str,
|
||||||
|
target: &str,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let args = [
|
let args = [
|
||||||
("InstanceID", instance_id_str.as_str()),
|
("InstanceID", instance_id_str.as_str()),
|
||||||
@@ -131,7 +141,11 @@ impl AvTransportClient {
|
|||||||
/// This should configure the *next* track to be played after the current one.
|
/// This should configure the *next* track to be played after the current one.
|
||||||
/// Many renderers do NOT implement this action; in that case the method
|
/// Many renderers do NOT implement this action; in that case the method
|
||||||
/// returns an error derived from the UPnP error code.
|
/// returns an error derived from the UPnP error code.
|
||||||
pub fn set_next_av_transport_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> {
|
pub fn set_next_av_transport_uri(
|
||||||
|
&self,
|
||||||
|
next_uri: &str,
|
||||||
|
next_meta: &str,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
let args = [
|
let args = [
|
||||||
("InstanceID", "0"),
|
("InstanceID", "0"),
|
||||||
("NextURI", next_uri),
|
("NextURI", next_uri),
|
||||||
@@ -150,10 +164,9 @@ impl AvTransportClient {
|
|||||||
if let Some(env) = &call_result.envelope {
|
if let Some(env) = &call_result.envelope {
|
||||||
if let Some(upnp_error) = parse_upnp_error(env) {
|
if let Some(upnp_error) = parse_upnp_error(env) {
|
||||||
if is_set_next_not_supported_error(&upnp_error) {
|
if is_set_next_not_supported_error(&upnp_error) {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::upnp_operation_not_supported(
|
||||||
"Renderer does not support AVTransport.SetNextAVTransportURI (UPnP error {}: {})",
|
"SetNextAVTransportURI",
|
||||||
upnp_error.error_code,
|
"AVTransport",
|
||||||
upnp_error.error_description
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,43 +179,9 @@ impl AvTransportClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo, ControlPointError> {
|
||||||
if !call_result.status.is_success() {
|
|
||||||
if let Some(env) = &call_result.envelope {
|
|
||||||
if let Some(upnp_error) = parse_upnp_error(env) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{action} failed with UPnP error {}: {} (HTTP status {})",
|
|
||||||
upnp_error.error_code,
|
|
||||||
upnp_error.error_description,
|
|
||||||
call_result.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{action} failed with HTTP status {} and body: {}",
|
|
||||||
call_result.status,
|
|
||||||
call_result.raw_body
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(env) = &call_result.envelope {
|
|
||||||
if let Some(upnp_error) = parse_upnp_error(env) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{action} returned UPnP error {}: {} (HTTP status {})",
|
|
||||||
upnp_error.error_code,
|
|
||||||
upnp_error.error_description,
|
|
||||||
call_result.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo> {
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing GetTransportInfoResponse element in SOAP body"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetTransportInfoResponse element in SOAP body")))?;
|
||||||
|
|
||||||
let current_transport_state = extract_child_text(response, "CurrentTransportState")?;
|
let current_transport_state = extract_child_text(response, "CurrentTransportState")?;
|
||||||
let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?;
|
let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?;
|
||||||
@@ -289,18 +268,6 @@ fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a E
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
|
|
||||||
let child = find_child_with_suffix(parent, suffix)
|
|
||||||
.ok_or_else(|| anyhow!("Missing {suffix} element in GetTransportInfoResponse"))?;
|
|
||||||
|
|
||||||
let text = child
|
|
||||||
.get_text()
|
|
||||||
.map(|t| t.trim().to_string())
|
|
||||||
.filter(|t| !t.is_empty())
|
|
||||||
.ok_or_else(|| anyhow!("{suffix} element missing text in GetTransportInfoResponse"))?;
|
|
||||||
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -397,7 +364,7 @@ pub struct PositionInfo {
|
|||||||
|
|
||||||
impl AvTransportClient {
|
impl AvTransportClient {
|
||||||
/// AVTransport:1 — GetPositionInfo
|
/// AVTransport:1 — GetPositionInfo
|
||||||
pub fn get_position_info(&self, instance_id: u32) -> Result<PositionInfo> {
|
pub fn get_position_info(&self, instance_id: u32) -> Result<PositionInfo, ControlPointError> {
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let args = [("InstanceID", instance_id_str.as_str())];
|
let args = [("InstanceID", instance_id_str.as_str())];
|
||||||
|
|
||||||
@@ -409,24 +376,24 @@ impl AvTransportClient {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
if !call_result.status.is_success() {
|
if !call_result.status.is_success() {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetPositionInfo failed with HTTP status {}",
|
"GetPositionInfo failed with HTTP status {}",
|
||||||
call_result.status
|
call_result.status
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result
|
||||||
.envelope
|
.envelope
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetPositionInfo response"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetPositionInfo response")))?;
|
||||||
|
|
||||||
parse_position_info(envelope)
|
parse_position_info(envelope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo> {
|
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo, ControlPointError> {
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing GetPositionInfoResponse element"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element")))?;
|
||||||
|
|
||||||
// Helpers allow missing text (AVTransport allows empty durations)
|
// Helpers allow missing text (AVTransport allows empty durations)
|
||||||
fn opt(parent: &Element, name: &str) -> Option<String> {
|
fn opt(parent: &Element, name: &str) -> Option<String> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// pmocontrol/src/capabilities.rs
|
// pmocontrol/src/capabilities.rs
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
|
|
||||||
/// Logical playback position across backends.
|
/// Logical playback position across backends.
|
||||||
///
|
///
|
||||||
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que
|
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que
|
||||||
@@ -16,7 +18,7 @@ pub struct PlaybackPositionInfo {
|
|||||||
pub track_uri: Option<String>, // Current track URI
|
pub track_uri: Option<String>, // Current track URI
|
||||||
}
|
}
|
||||||
pub trait PlaybackPosition {
|
pub trait PlaybackPosition {
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo>;
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High-level playback state across backends.
|
/// High-level playback state across backends.
|
||||||
@@ -69,7 +71,7 @@ impl PlaybackState {
|
|||||||
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
|
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
|
||||||
/// For OpenHome, a future implementation will adapt from OH Info/Time.
|
/// For OpenHome, a future implementation will adapt from OH Info/Time.
|
||||||
pub trait PlaybackStatus {
|
pub trait PlaybackStatus {
|
||||||
fn playback_state(&self) -> Result<PlaybackState>;
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abstraction générique des capacités de transport (lecture / pause / stop / seek)
|
/// Abstraction générique des capacités de transport (lecture / pause / stop / seek)
|
||||||
@@ -80,32 +82,32 @@ pub trait TransportControl {
|
|||||||
/// Selon l'implémentation, cette méthode peut soit :
|
/// Selon l'implémentation, cette méthode peut soit :
|
||||||
/// - faire un "Set...URI" + "Play" (cas UPnP AV),
|
/// - faire un "Set...URI" + "Play" (cas UPnP AV),
|
||||||
/// - ou configurer la file de lecture (cas OpenHome, etc.).
|
/// - ou configurer la file de lecture (cas OpenHome, etc.).
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()>;
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
/// Démarre ou reprend la lecture.
|
/// Démarre ou reprend la lecture.
|
||||||
fn play(&self) -> Result<()>;
|
fn play(&self) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
/// Met la lecture en pause.
|
/// Met la lecture en pause.
|
||||||
fn pause(&self) -> Result<()>;
|
fn pause(&self) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
/// Arrête la lecture.
|
/// Arrête la lecture.
|
||||||
fn stop(&self) -> Result<()>;
|
fn stop(&self) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
/// Seek à un temps relatif (HH:MM:SS) si supporté.
|
/// Seek à un temps relatif (HH:MM:SS) si supporté.
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()>;
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abstraction générique des capacités de contrôle de volume / mute.
|
/// Abstraction générique des capacités de contrôle de volume / mute.
|
||||||
pub trait VolumeControl {
|
pub trait VolumeControl {
|
||||||
/// Retourne le volume logique courant (échelle dépendante du renderer).
|
/// Retourne le volume logique courant (échelle dépendante du renderer).
|
||||||
fn volume(&self) -> Result<u16>;
|
fn volume(&self) -> Result<u16, ControlPointError>;
|
||||||
|
|
||||||
/// Définit le volume logique (échelle dépendante du renderer).
|
/// Définit le volume logique (échelle dépendante du renderer).
|
||||||
fn set_volume(&self, v: u16) -> Result<()>;
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
/// Indique si le renderer est muet (mute activé).
|
/// Indique si le renderer est muet (mute activé).
|
||||||
fn mute(&self) -> Result<bool>;
|
fn mute(&self) -> Result<bool, ControlPointError>;
|
||||||
|
|
||||||
/// Active ou désactive le mute.
|
/// Active ou désactive le mute.
|
||||||
fn set_mute(&self, m: bool) -> Result<()>;
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ use anyhow::{Result, anyhow};
|
|||||||
|
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::DeviceIdentity;
|
||||||
use crate::capabilities::{
|
use crate::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||||
VolumeControl,
|
VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
|
use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
|
||||||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
use crate::errors::ControlPointError;
|
||||||
|
use crate::model::{RendererInfo};
|
||||||
|
|
||||||
use rust_cast::{
|
use rust_cast::{
|
||||||
CastDevice, ChannelMessage,
|
CastDevice, ChannelMessage,
|
||||||
@@ -46,7 +48,6 @@ const DEFAULT_CHROMECAST_PORT: u16 = 8009;
|
|||||||
/// spawned to handle heartbeat responses from the device.
|
/// spawned to handle heartbeat responses from the device.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ChromecastRenderer {
|
pub struct ChromecastRenderer {
|
||||||
pub info: RendererInfo,
|
|
||||||
host: String,
|
host: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
stop_signal: Arc<Mutex<bool>>,
|
stop_signal: Arc<Mutex<bool>>,
|
||||||
@@ -58,7 +59,6 @@ pub struct ChromecastRenderer {
|
|||||||
impl std::fmt::Debug for ChromecastRenderer {
|
impl std::fmt::Debug for ChromecastRenderer {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("ChromecastRenderer")
|
f.debug_struct("ChromecastRenderer")
|
||||||
.field("info", &self.info)
|
|
||||||
.field("host", &self.host)
|
.field("host", &self.host)
|
||||||
.field("port", &self.port)
|
.field("port", &self.port)
|
||||||
.finish()
|
.finish()
|
||||||
@@ -76,29 +76,31 @@ fn ensure_crypto_provider_initialized() {
|
|||||||
INIT.call_once(|| {
|
INIT.call_once(|| {
|
||||||
// Install the default CryptoProvider (aws-lc-rs or ring, depending on features)
|
// Install the default CryptoProvider (aws-lc-rs or ring, depending on features)
|
||||||
let _ = rustls::crypto::CryptoProvider::install_default(
|
let _ = rustls::crypto::CryptoProvider::install_default(
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
rustls::crypto::aws_lc_rs::default_provider(),
|
||||||
);
|
);
|
||||||
tracing::debug!("Rustls CryptoProvider initialized for Chromecast connections");
|
tracing::debug!("Rustls CryptoProvider initialized for Chromecast connections");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to connect to a Chromecast device.
|
/// Helper function to connect to a Chromecast device.
|
||||||
fn connect_to_device<'a>(host: &'a str, port: u16) -> Result<CastDevice<'a>> {
|
fn connect_to_device<'a>(host: &'a str, port: u16) -> Result<CastDevice<'a>, ControlPointError> {
|
||||||
// Ensure rustls crypto provider is initialized before any TLS connection
|
// Ensure rustls crypto provider is initialized before any TLS connection
|
||||||
ensure_crypto_provider_initialized();
|
ensure_crypto_provider_initialized();
|
||||||
|
|
||||||
let device = CastDevice::connect_without_host_verification(host, port)
|
let device = CastDevice::connect_without_host_verification(host, port)
|
||||||
.map_err(|e| anyhow!("Failed to connect to Chromecast: {}", e))?;
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to Chromecast: {}", e)))?;
|
||||||
|
|
||||||
device.connection
|
device
|
||||||
|
.connection
|
||||||
.connect(DEFAULT_DESTINATION_ID.to_string())
|
.connect(DEFAULT_DESTINATION_ID.to_string())
|
||||||
.map_err(|e| anyhow!("Failed to connect channel: {}", e))?;
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect channel: {}", e)))?;
|
||||||
|
|
||||||
// Send initial gre to establish heartbeat communication
|
// Send initial gre to establish heartbeat communication
|
||||||
// This is critical per rust_caster.rs example
|
// This is critical per rust_caster.rs example
|
||||||
device.heartbeat
|
device
|
||||||
|
.heartbeat
|
||||||
.ping()
|
.ping()
|
||||||
.map_err(|e| anyhow!("Failed to send initial heartbeat ping: {}", e))?;
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to send initial heartbeat ping: {}", e)))?;
|
||||||
|
|
||||||
Ok(device)
|
Ok(device)
|
||||||
}
|
}
|
||||||
@@ -113,62 +115,42 @@ fn map_player_state(player_state: &CastPlayerState) -> PlaybackState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl ChromecastRenderer {
|
impl ChromecastRenderer {
|
||||||
/// Creates a new ChromecastRenderer from RendererInfo.
|
/// Creates a new ChromecastRenderer from RendererInfo.
|
||||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"ChromecastRenderer::from_renderer_info location={} for {}",
|
"ChromecastRenderer::from_renderer_info location={} for {}",
|
||||||
info.location,
|
info.location(),
|
||||||
info.friendly_name
|
info.friendly_name()
|
||||||
);
|
);
|
||||||
|
|
||||||
let host = extract_host_from_location(&info.location)
|
let host = extract_host_from_location(info.location())
|
||||||
.ok_or_else(|| anyhow!("Invalid Chromecast location: {}", info.location))?;
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("Invalid Chromecast location: {}", info.location())))?;
|
||||||
|
|
||||||
let port = extract_port_from_location(&info.location)
|
let port = extract_port_from_location(info.location()).unwrap_or(DEFAULT_CHROMECAST_PORT);
|
||||||
.unwrap_or(DEFAULT_CHROMECAST_PORT);
|
|
||||||
|
|
||||||
let stop_signal = Arc::new(Mutex::new(false));
|
let stop_signal = Arc::new(Mutex::new(false));
|
||||||
let thread_handle = Arc::new(Mutex::new(None));
|
let thread_handle = Arc::new(Mutex::new(None));
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"ChromecastRenderer created for {} with host={} port={}",
|
"ChromecastRenderer created for {} with host={} port={}",
|
||||||
info.friendly_name,
|
info.friendly_name(),
|
||||||
host,
|
host,
|
||||||
port
|
port
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
info,
|
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
stop_signal,
|
stop_signal,
|
||||||
thread_handle,
|
thread_handle,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/// Returns the renderer ID.
|
|
||||||
pub fn id(&self) -> &RendererId {
|
|
||||||
&self.info.id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the friendly name.
|
|
||||||
pub fn friendly_name(&self) -> &str {
|
|
||||||
&self.info.friendly_name
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the protocol.
|
|
||||||
pub fn protocol(&self) -> &RendererProtocol {
|
|
||||||
&self.info.protocol
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the renderer info.
|
|
||||||
pub fn info(&self) -> &RendererInfo {
|
|
||||||
&self.info
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportControl for ChromecastRenderer {
|
impl TransportControl for ChromecastRenderer {
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: play_uri({})", uri);
|
debug!("ChromecastRenderer: play_uri({})", uri);
|
||||||
|
|
||||||
// Signal any existing play thread to stop
|
// Signal any existing play thread to stop
|
||||||
@@ -184,8 +166,7 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
|
|
||||||
// Wait for thread to finish (it should see stop_signal and exit)
|
// Wait for thread to finish (it should see stop_signal and exit)
|
||||||
// Note: device.receive() may block, so thread might take time to notice stop_signal
|
// Note: device.receive() may block, so thread might take time to notice stop_signal
|
||||||
let join_result = std::thread::spawn(move || handle.join())
|
let join_result = std::thread::spawn(move || handle.join()).join();
|
||||||
.join();
|
|
||||||
|
|
||||||
match join_result {
|
match join_result {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
@@ -225,7 +206,10 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Launch DefaultMediaReceiver app
|
// Launch DefaultMediaReceiver app
|
||||||
let app = match device.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) {
|
let app = match device
|
||||||
|
.receiver
|
||||||
|
.launch_app(&CastDeviceApp::DefaultMediaReceiver)
|
||||||
|
{
|
||||||
Ok(app) => app,
|
Ok(app) => app,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to launch DefaultMediaReceiver: {}", e);
|
tracing::error!("Failed to launch DefaultMediaReceiver: {}", e);
|
||||||
@@ -249,11 +233,10 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
metadata: None,
|
metadata: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match device.media.load(
|
match device
|
||||||
app.transport_id.as_str(),
|
.media
|
||||||
app.session_id.as_str(),
|
.load(app.transport_id.as_str(), app.session_id.as_str(), &media)
|
||||||
&media,
|
{
|
||||||
) {
|
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
tracing::info!("Media loaded successfully: {:?}", status);
|
tracing::info!("Media loaded successfully: {:?}", status);
|
||||||
}
|
}
|
||||||
@@ -314,63 +297,87 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play(&self) -> Result<()> {
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: play()");
|
debug!("ChromecastRenderer: play()");
|
||||||
|
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
// Get receiver status to find the active app
|
// Get receiver status to find the active app
|
||||||
let status = device.receiver.get_status()
|
let status = device
|
||||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
.receiver
|
||||||
|
.get_status()
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||||
|
|
||||||
let app = status.applications.first()
|
let app = status
|
||||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
.applications
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||||
|
|
||||||
// Connect to the app
|
// Connect to the app
|
||||||
device.connection.connect(app.transport_id.as_str())
|
device
|
||||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
.connection
|
||||||
|
.connect(app.transport_id.as_str())
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||||
|
|
||||||
// Get media status
|
// Get media status
|
||||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
let media_status = device
|
||||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
.media
|
||||||
|
.get_status(app.transport_id.as_str(), None)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||||
|
|
||||||
let media_entry = media_status.entries.first()
|
let media_entry = media_status
|
||||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
.entries
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||||
|
|
||||||
// Send play command
|
// Send play command
|
||||||
device.media.play(app.transport_id.as_str(), media_entry.media_session_id)
|
device
|
||||||
.map_err(|e| anyhow!("Failed to play: {}", e))?;
|
.media
|
||||||
|
.play(app.transport_id.as_str(), media_entry.media_session_id)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to play: {}", e)))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pause(&self) -> Result<()> {
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: pause()");
|
debug!("ChromecastRenderer: pause()");
|
||||||
|
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device.receiver.get_status()
|
let status = device
|
||||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
.receiver
|
||||||
|
.get_status()
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||||
|
|
||||||
let app = status.applications.first()
|
let app = status
|
||||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
.applications
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||||
|
|
||||||
device.connection.connect(app.transport_id.as_str())
|
device
|
||||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
.connection
|
||||||
|
.connect(app.transport_id.as_str())
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||||
|
|
||||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
let media_status = device
|
||||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
.media
|
||||||
|
.get_status(app.transport_id.as_str(), None)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||||
|
|
||||||
let media_entry = media_status.entries.first()
|
let media_entry = media_status
|
||||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
.entries
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||||
|
|
||||||
device.media.pause(app.transport_id.as_str(), media_entry.media_session_id)
|
device
|
||||||
.map_err(|e| anyhow!("Failed to pause: {}", e))?;
|
.media
|
||||||
|
.pause(app.transport_id.as_str(), media_entry.media_session_id)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to pause: {}", e)))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&self) -> Result<()> {
|
fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: stop()");
|
debug!("ChromecastRenderer: stop()");
|
||||||
|
|
||||||
// Signal the play thread to stop
|
// Signal the play thread to stop
|
||||||
@@ -385,81 +392,113 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
// Also send stop command to the device
|
// Also send stop command to the device
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device.receiver.get_status()
|
let status = device
|
||||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
.receiver
|
||||||
|
.get_status()
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||||
|
|
||||||
let app = status.applications.first()
|
let app = status
|
||||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
.applications
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||||
|
|
||||||
device.connection.connect(app.transport_id.as_str())
|
device
|
||||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
.connection
|
||||||
|
.connect(app.transport_id.as_str())
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||||
|
|
||||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
let media_status = device
|
||||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
.media
|
||||||
|
.get_status(app.transport_id.as_str(), None)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||||
|
|
||||||
let media_entry = media_status.entries.first()
|
let media_entry = media_status
|
||||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
.entries
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||||
|
|
||||||
device.media.stop(app.transport_id.as_str(), media_entry.media_session_id)
|
device
|
||||||
.map_err(|e| anyhow!("Failed to stop: {}", e))?;
|
.media
|
||||||
|
.stop(app.transport_id.as_str(), media_entry.media_session_id)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to stop: {}", e)))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
|
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
|
||||||
|
|
||||||
// Parse HH:MM:SS to seconds
|
// Parse HH:MM:SS to seconds
|
||||||
let parts: Vec<&str> = hhmmss.split(':').collect();
|
let parts: Vec<&str> = hhmmss.split(':').collect();
|
||||||
if parts.len() != 3 {
|
if parts.len() != 3 {
|
||||||
return Err(anyhow!("Invalid time format, expected HH:MM:SS: {}", hhmmss));
|
return Err(ControlPointError::ChromecastError(format!(
|
||||||
|
"Invalid time format, expected HH:MM:SS: {}",
|
||||||
|
hhmmss
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let hours: u32 = parts[0].parse()
|
let hours: u32 = parts[0]
|
||||||
.map_err(|_| anyhow!("Invalid hours in time: {}", hhmmss))?;
|
.parse()
|
||||||
let minutes: u32 = parts[1].parse()
|
.map_err(|_| ControlPointError::ChromecastError(format!("Invalid hours in time: {}", hhmmss)))?;
|
||||||
.map_err(|_| anyhow!("Invalid minutes in time: {}", hhmmss))?;
|
let minutes: u32 = parts[1]
|
||||||
let seconds: u32 = parts[2].parse()
|
.parse()
|
||||||
.map_err(|_| anyhow!("Invalid seconds in time: {}", hhmmss))?;
|
.map_err(|_| ControlPointError::ChromecastError(format!("Invalid minutes in time: {}", hhmmss)))?;
|
||||||
|
let seconds: u32 = parts[2]
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ControlPointError::ChromecastError(format!("Invalid seconds in time: {}", hhmmss)))?;
|
||||||
|
|
||||||
let total_seconds = (hours * 3600 + minutes * 60 + seconds) as f32;
|
let total_seconds = (hours * 3600 + minutes * 60 + seconds) as f32;
|
||||||
|
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device.receiver.get_status()
|
let status = device
|
||||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
.receiver
|
||||||
|
.get_status()
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||||
|
|
||||||
let app = status.applications.first()
|
let app = status
|
||||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
.applications
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||||
|
|
||||||
device.connection.connect(app.transport_id.as_str())
|
device
|
||||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
.connection
|
||||||
|
.connect(app.transport_id.as_str())
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||||
|
|
||||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
let media_status = device
|
||||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
.media
|
||||||
|
.get_status(app.transport_id.as_str(), None)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||||
|
|
||||||
let media_entry = media_status.entries.first()
|
let media_entry = media_status
|
||||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
.entries
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||||
|
|
||||||
device.media.seek(
|
device
|
||||||
app.transport_id.as_str(),
|
.media
|
||||||
media_entry.media_session_id,
|
.seek(
|
||||||
Some(total_seconds),
|
app.transport_id.as_str(),
|
||||||
None,
|
media_entry.media_session_id,
|
||||||
)
|
Some(total_seconds),
|
||||||
.map_err(|e| anyhow!("Failed to seek: {}", e))?;
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to seek: {}", e)))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackStatus for ChromecastRenderer {
|
impl PlaybackStatus for ChromecastRenderer {
|
||||||
fn playback_state(&self) -> Result<PlaybackState> {
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
// Get receiver status to find the active app
|
// Get receiver status to find the active app
|
||||||
let status = device.receiver.get_status()
|
let status = device
|
||||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
.receiver
|
||||||
|
.get_status()
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||||
|
|
||||||
// If no app is running, return NoMedia
|
// If no app is running, return NoMedia
|
||||||
let app = match status.applications.first() {
|
let app = match status.applications.first() {
|
||||||
@@ -468,12 +507,16 @@ impl PlaybackStatus for ChromecastRenderer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Connect to the app
|
// Connect to the app
|
||||||
device.connection.connect(app.transport_id.as_str())
|
device
|
||||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
.connection
|
||||||
|
.connect(app.transport_id.as_str())
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||||
|
|
||||||
// Get media status
|
// Get media status
|
||||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
let media_status = device
|
||||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
.media
|
||||||
|
.get_status(app.transport_id.as_str(), None)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||||
|
|
||||||
// If no media entry, return NoMedia
|
// If no media entry, return NoMedia
|
||||||
let media_entry = match media_status.entries.first() {
|
let media_entry = match media_status.entries.first() {
|
||||||
@@ -486,37 +529,49 @@ impl PlaybackStatus for ChromecastRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackPosition for ChromecastRenderer {
|
impl PlaybackPosition for ChromecastRenderer {
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
// Get receiver status to find the active app
|
// Get receiver status to find the active app
|
||||||
let status = device.receiver.get_status()
|
let status = device
|
||||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
.receiver
|
||||||
|
.get_status()
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||||
|
|
||||||
let app = status.applications.first()
|
let app = status
|
||||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
.applications
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||||
|
|
||||||
// Connect to the app
|
// Connect to the app
|
||||||
device.connection.connect(app.transport_id.as_str())
|
device
|
||||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
.connection
|
||||||
|
.connect(app.transport_id.as_str())
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||||
|
|
||||||
// Get media status
|
// Get media status
|
||||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
let media_status = device
|
||||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
.media
|
||||||
|
.get_status(app.transport_id.as_str(), None)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||||
|
|
||||||
let media_entry = media_status.entries.first()
|
let media_entry = media_status
|
||||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
.entries
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||||
|
|
||||||
// Extract position information
|
// Extract position information
|
||||||
let rel_time = media_entry.current_time
|
let rel_time = media_entry
|
||||||
|
.current_time
|
||||||
.map(|time| format_time_hhmmss(time as f64));
|
.map(|time| format_time_hhmmss(time as f64));
|
||||||
|
|
||||||
let track_duration = media_entry.media.as_ref()
|
let track_duration = media_entry
|
||||||
|
.media
|
||||||
|
.as_ref()
|
||||||
.and_then(|m| m.duration)
|
.and_then(|m| m.duration)
|
||||||
.map(|dur| format_time_hhmmss(dur as f64));
|
.map(|dur| format_time_hhmmss(dur as f64));
|
||||||
|
|
||||||
let track_uri = media_entry.media.as_ref()
|
let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone());
|
||||||
.map(|m| m.content_id.clone());
|
|
||||||
|
|
||||||
Ok(PlaybackPositionInfo {
|
Ok(PlaybackPositionInfo {
|
||||||
track: Some(1),
|
track: Some(1),
|
||||||
@@ -600,11 +655,13 @@ fn format_time_hhmmss(seconds: f64) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VolumeControl for ChromecastRenderer {
|
impl VolumeControl for ChromecastRenderer {
|
||||||
fn volume(&self) -> Result<u16> {
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device.receiver.get_status()
|
let status = device
|
||||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
.receiver
|
||||||
|
.get_status()
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||||
|
|
||||||
if let Some(level) = status.volume.level {
|
if let Some(level) = status.volume.level {
|
||||||
Ok((level * 100.0) as u16)
|
Ok((level * 100.0) as u16)
|
||||||
@@ -613,34 +670,40 @@ impl VolumeControl for ChromecastRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&self, volume: u16) -> Result<()> {
|
fn set_volume(&self, volume: u16) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: set_volume({})", volume);
|
debug!("ChromecastRenderer: set_volume({})", volume);
|
||||||
|
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let level = (volume as f32) / 100.0;
|
let level = (volume as f32) / 100.0;
|
||||||
device.receiver.set_volume(level)
|
device
|
||||||
.map_err(|e| anyhow!("Failed to set volume: {}", e))?;
|
.receiver
|
||||||
|
.set_volume(level)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set volume: {}", e)))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool> {
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device.receiver.get_status()
|
let status = device
|
||||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
.receiver
|
||||||
|
.get_status()
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||||
|
|
||||||
Ok(status.volume.muted.unwrap_or(false))
|
Ok(status.volume.muted.unwrap_or(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_mute(&self, mute: bool) -> Result<()> {
|
fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: set_mute({})", mute);
|
debug!("ChromecastRenderer: set_mute({})", mute);
|
||||||
|
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
device.receiver.set_volume(mute)
|
device
|
||||||
.map_err(|e| anyhow!("Failed to set mute: {}", e))?;
|
.receiver
|
||||||
|
.set_volume(mute)
|
||||||
|
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set mute: {}", e)))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
|
use crate::{errors::ControlPointError, soap_client::{
|
||||||
use pmoupnp::soap::SoapEnvelope;
|
SoapCallResult, ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix, invoke_upnp_action, parse_upnp_error
|
||||||
use xmltree::{Element, XMLNode};
|
}};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ConnectionManagerClient {
|
pub struct ConnectionManagerClient {
|
||||||
@@ -38,7 +38,7 @@ impl ConnectionManagerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GetProtocolInfo
|
/// GetProtocolInfo
|
||||||
pub fn get_protocol_info(&self) -> Result<ProtocolInfo> {
|
pub fn get_protocol_info(&self) -> Result<ProtocolInfo, ControlPointError> {
|
||||||
let call_result = invoke_upnp_action(
|
let call_result = invoke_upnp_action(
|
||||||
&self.control_url,
|
&self.control_url,
|
||||||
&self.service_type,
|
&self.service_type,
|
||||||
@@ -51,19 +51,19 @@ impl ConnectionManagerClient {
|
|||||||
let envelope = call_result
|
let envelope = call_result
|
||||||
.envelope
|
.envelope
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetProtocolInfo response"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetProtocolInfo response")))?;
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetProtocolInfo returned UPnP error {}: {} (HTTP status {})",
|
"GetProtocolInfo returned UPnP error {}: {} (HTTP status {})",
|
||||||
err.error_code,
|
err.error_code,
|
||||||
err.error_description,
|
err.error_description,
|
||||||
call_result.status
|
call_result.status
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing GetProtocolInfoResponse element in SOAP body"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetProtocolInfoResponse element in SOAP body")))?;
|
||||||
|
|
||||||
let source_text = extract_child_text_allow_empty(response, "Source")?;
|
let source_text = extract_child_text_allow_empty(response, "Source")?;
|
||||||
let sink_text = extract_child_text_allow_empty(response, "Sink")?;
|
let sink_text = extract_child_text_allow_empty(response, "Sink")?;
|
||||||
@@ -75,7 +75,7 @@ impl ConnectionManagerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GetCurrentConnectionIDs
|
/// GetCurrentConnectionIDs
|
||||||
pub fn get_current_connection_ids(&self) -> Result<Vec<i32>> {
|
pub fn get_current_connection_ids(&self) -> Result<Vec<i32>, ControlPointError> {
|
||||||
let call_result = invoke_upnp_action(
|
let call_result = invoke_upnp_action(
|
||||||
&self.control_url,
|
&self.control_url,
|
||||||
&self.service_type,
|
&self.service_type,
|
||||||
@@ -88,21 +88,21 @@ impl ConnectionManagerClient {
|
|||||||
let envelope = call_result
|
let envelope = call_result
|
||||||
.envelope
|
.envelope
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionIDs response"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionIDs response")))?;
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})",
|
"GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})",
|
||||||
err.error_code,
|
err.error_code,
|
||||||
err.error_description,
|
err.error_description,
|
||||||
call_result.status
|
call_result.status
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response =
|
let response =
|
||||||
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse")
|
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse")
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow!("Missing GetCurrentConnectionIDsResponse element in SOAP body")
|
ControlPointError::UpnpError(format!("Missing GetCurrentConnectionIDsResponse element in SOAP body"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?;
|
let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?;
|
||||||
@@ -120,7 +120,7 @@ impl ConnectionManagerClient {
|
|||||||
}
|
}
|
||||||
let parsed = value
|
let parsed = value
|
||||||
.parse::<i32>()
|
.parse::<i32>()
|
||||||
.map_err(|_| anyhow!("Invalid ConnectionID value: {}", value))?;
|
.map_err(|_| ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value)))?;
|
||||||
ids.push(parsed);
|
ids.push(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ impl ConnectionManagerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GetCurrentConnectionInfo
|
/// GetCurrentConnectionInfo
|
||||||
pub fn get_current_connection_info(&self, connection_id: i32) -> Result<ConnectionInfo> {
|
pub fn get_current_connection_info(&self, connection_id: i32) -> Result<ConnectionInfo, ControlPointError> {
|
||||||
let connection_id_str = connection_id.to_string();
|
let connection_id_str = connection_id.to_string();
|
||||||
let args = [("ConnectionID", connection_id_str.as_str())];
|
let args = [("ConnectionID", connection_id_str.as_str())];
|
||||||
|
|
||||||
@@ -144,30 +144,30 @@ impl ConnectionManagerClient {
|
|||||||
let envelope = call_result
|
let envelope = call_result
|
||||||
.envelope
|
.envelope
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionInfo response"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionInfo response")))?;
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})",
|
"GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})",
|
||||||
err.error_code,
|
err.error_code,
|
||||||
err.error_description,
|
err.error_description,
|
||||||
call_result.status
|
call_result.status
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response =
|
let response =
|
||||||
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse")
|
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse")
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow!("Missing GetCurrentConnectionInfoResponse element in SOAP body")
|
ControlPointError::UpnpError(format!("Missing GetCurrentConnectionInfoResponse element in SOAP body"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let rcs_id = extract_child_text(response, "RcsID")?
|
let rcs_id = extract_child_text(response, "RcsID")?
|
||||||
.parse::<i32>()
|
.parse::<i32>()
|
||||||
.map_err(|_| anyhow!("Invalid RcsID value in response"))?;
|
.map_err(|_| ControlPointError::UpnpError(format!("Invalid RcsID value in response")))?;
|
||||||
|
|
||||||
let av_transport_id = extract_child_text(response, "AVTransportID")?
|
let av_transport_id = extract_child_text(response, "AVTransportID")?
|
||||||
.parse::<i32>()
|
.parse::<i32>()
|
||||||
.map_err(|_| anyhow!("Invalid AVTransportID value in response"))?;
|
.map_err(|_| ControlPointError::UpnpError(format!("Invalid AVTransportID value in response")))?;
|
||||||
|
|
||||||
let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?;
|
let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?;
|
||||||
let peer_connection_manager =
|
let peer_connection_manager =
|
||||||
@@ -175,7 +175,7 @@ impl ConnectionManagerClient {
|
|||||||
|
|
||||||
let peer_connection_id = extract_child_text(response, "PeerConnectionID")?
|
let peer_connection_id = extract_child_text(response, "PeerConnectionID")?
|
||||||
.parse::<i32>()
|
.parse::<i32>()
|
||||||
.map_err(|_| anyhow!("Invalid PeerConnectionID value in response"))?;
|
.map_err(|_| ControlPointError::UpnpError(format!("Invalid PeerConnectionID value in response")))?;
|
||||||
|
|
||||||
let direction = extract_child_text(response, "Direction")?;
|
let direction = extract_child_text(response, "Direction")?;
|
||||||
let status = extract_child_text(response, "Status")?;
|
let status = extract_child_text(response, "Status")?;
|
||||||
@@ -205,90 +205,3 @@ fn split_list(value: &str) -> Vec<String> {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
|
||||||
if call_result.status.is_success() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(env) = &call_result.envelope {
|
|
||||||
if let Some(err) = parse_upnp_error(env) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{action} failed with UPnP error {}: {} (HTTP status {})",
|
|
||||||
err.error_code,
|
|
||||||
err.error_description,
|
|
||||||
call_result.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(anyhow!(
|
|
||||||
"{action} failed with HTTP status {} and body: {}",
|
|
||||||
call_result.status,
|
|
||||||
call_result.raw_body
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct UpnpError {
|
|
||||||
pub error_code: u32,
|
|
||||||
pub error_description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
|
|
||||||
let fault = find_child_with_suffix(&envelope.body.content, "Fault")?;
|
|
||||||
let detail = find_child_with_suffix(fault, "detail")?;
|
|
||||||
let upnp_error = find_child_with_suffix(detail, "UPnPError")?;
|
|
||||||
|
|
||||||
let error_code_elem = upnp_error.children.iter().find_map(|node| match node {
|
|
||||||
XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem),
|
|
||||||
_ => None,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let binding = error_code_elem.get_text()?;
|
|
||||||
let error_code_text = binding.trim();
|
|
||||||
let error_code = error_code_text.parse::<u32>().ok()?;
|
|
||||||
|
|
||||||
let error_description = upnp_error
|
|
||||||
.children
|
|
||||||
.iter()
|
|
||||||
.find_map(|node| match node {
|
|
||||||
XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => {
|
|
||||||
elem.get_text().map(|t| t.trim().to_string())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| String::from(""));
|
|
||||||
|
|
||||||
Some(UpnpError {
|
|
||||||
error_code,
|
|
||||||
error_description,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
|
|
||||||
parent.children.iter().find_map(|node| match node {
|
|
||||||
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
|
|
||||||
let text = extract_child_text_allow_empty(parent, suffix)?;
|
|
||||||
if text.is_empty() {
|
|
||||||
return Err(anyhow!("{suffix} element missing text in response"));
|
|
||||||
}
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_child_text_allow_empty(parent: &Element, suffix: &str) -> Result<String> {
|
|
||||||
let child = find_child_with_suffix(parent, suffix)
|
|
||||||
.ok_or_else(|| anyhow!("Missing {suffix} element in response"))?;
|
|
||||||
|
|
||||||
let text = child
|
|
||||||
.get_text()
|
|
||||||
.map(|t| t.trim().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ use crate::control_point::openhome_queue::OpenHomeQueue;
|
|||||||
use crate::discovery::DiscoveryManager;
|
use crate::discovery::DiscoveryManager;
|
||||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||||
use crate::media_server::{
|
use crate::media_server::{
|
||||||
playback_item_from_entry, MediaBrowser, MediaServerInfo, MusicServer, ServerId,
|
playback_item_from_entry, MediaBrowser, UpnpMediaServer, UpnpMediaServer, ServerId,
|
||||||
};
|
};
|
||||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
|
use crate::model::{MediaServerEvent, RendererEvent, ServiceId, RendererInfo};
|
||||||
use crate::music_renderer::{
|
use crate::music_renderer::MusicRenderer;
|
||||||
set_openhome_queue_provider, OpenHomeQueueProvider, RendererRuntimeState,
|
|
||||||
};
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::openapi::{
|
use crate::openapi::{
|
||||||
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
||||||
@@ -52,11 +50,13 @@ use crate::openhome_client::parse_track_metadata_from_didl;
|
|||||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
use crate::openhome_renderer::{format_seconds, map_openhome_state};
|
use crate::openhome_renderer::{format_seconds, map_openhome_state};
|
||||||
use crate::provider::HttpXmlDescriptionProvider;
|
use crate::provider::HttpXmlDescriptionProvider;
|
||||||
use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
use crate::queue_interne::InternalQueue;
|
use crate::queue_interne::InternalQueue;
|
||||||
use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||||
use crate::upnp_renderer::UpnpRenderer;
|
use crate::upnp_renderer::UpnpRenderer;
|
||||||
use crate::MusicRenderer;
|
use crate::MusicRendererBackend;
|
||||||
|
use crate::MusicRendererInfo;
|
||||||
|
|
||||||
|
|
||||||
/// Optional attachment between a renderer playback queue and a server-side
|
/// Optional attachment between a renderer playback queue and a server-side
|
||||||
/// DIDL-Lite playlist container.
|
/// DIDL-Lite playlist container.
|
||||||
@@ -97,6 +97,7 @@ pub enum OpenHomeAccessError {
|
|||||||
/// et n'utiliser les événements SSE que comme signaux de rafraîchissement.
|
/// et n'utiliser les événements SSE que comme signaux de rafraîchissement.
|
||||||
pub struct ControlPoint {
|
pub struct ControlPoint {
|
||||||
registry: Arc<RwLock<DeviceRegistry>>,
|
registry: Arc<RwLock<DeviceRegistry>>,
|
||||||
|
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||||
event_bus: RendererEventBus,
|
event_bus: RendererEventBus,
|
||||||
media_event_bus: MediaServerEventBus,
|
media_event_bus: MediaServerEventBus,
|
||||||
runtime: Arc<RuntimeState>,
|
runtime: Arc<RuntimeState>,
|
||||||
@@ -105,13 +106,13 @@ pub struct ControlPoint {
|
|||||||
///
|
///
|
||||||
/// Key : RendererId
|
/// Key : RendererId
|
||||||
/// Value : PlaylistBinding
|
/// Value : PlaylistBinding
|
||||||
playlist_bindings: Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
playlist_bindings: Arc<Mutex<HashMap<ServiceId, PlaylistBinding>>>,
|
||||||
/// Cache of MusicRenderer instances to avoid recreating them.
|
/// Cache of MusicRenderer instances to avoid recreating them.
|
||||||
/// This is critical for Chromecast which maintains a persistent TLS connection.
|
/// This is critical for Chromecast which maintains a persistent TLS connection.
|
||||||
///
|
///
|
||||||
/// Key : RendererId
|
/// Key : RendererId
|
||||||
/// Value : MusicRenderer
|
/// Value : MusicRenderer
|
||||||
renderer_cache: Arc<Mutex<HashMap<RendererId, MusicRenderer>>>,
|
renderer_cache: Arc<Mutex<HashMap<ServiceId, MusicRendererBackend>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControlPoint {
|
impl ControlPoint {
|
||||||
@@ -123,9 +124,9 @@ impl ControlPoint {
|
|||||||
let event_bus = RendererEventBus::new();
|
let event_bus = RendererEventBus::new();
|
||||||
let media_event_bus = MediaServerEventBus::new();
|
let media_event_bus = MediaServerEventBus::new();
|
||||||
let runtime = Arc::new(RuntimeState::new());
|
let runtime = Arc::new(RuntimeState::new());
|
||||||
set_openhome_queue_provider(Arc::new(RuntimeOpenHomeQueueProvider {
|
// set_openhome_queue_provider(Arc::new(RuntimeOpenHomeQueueProvider {
|
||||||
runtime: Arc::clone(&runtime),
|
// runtime: Arc::clone(&runtime),
|
||||||
}));
|
// }));
|
||||||
let playlist_bindings = Arc::new(Mutex::new(HashMap::new()));
|
let playlist_bindings = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let renderer_cache = Arc::new(Mutex::new(HashMap::new()));
|
let renderer_cache = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
@@ -416,7 +417,7 @@ impl ControlPoint {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build a map of current renderer IDs for cleanup
|
// Build a map of current renderer IDs for cleanup
|
||||||
let current_ids: HashSet<RendererId> = infos.iter().map(|i| i.id.clone()).collect();
|
let current_ids: HashSet<ServiceId> = infos.iter().map(|i| i.id.clone()).collect();
|
||||||
|
|
||||||
// Remove offline renderers from shared cache
|
// Remove offline renderers from shared cache
|
||||||
{
|
{
|
||||||
@@ -425,7 +426,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get or create renderers from shared cache
|
// Get or create renderers from shared cache
|
||||||
let renderers: Vec<MusicRenderer> = infos
|
let renderers: Vec<MusicRendererBackend> = infos
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|info| {
|
.filter_map(|info| {
|
||||||
let id = info.id.clone();
|
let id = info.id.clone();
|
||||||
@@ -439,7 +440,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new renderer and add to cache
|
// Create new renderer and add to cache
|
||||||
if let Some(renderer) = MusicRenderer::from_registry_info(info, &runtime_cp.registry) {
|
if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, &runtime_cp.registry) {
|
||||||
let mut cache = runtime_cp.renderer_cache.lock().unwrap();
|
let mut cache = runtime_cp.renderer_cache.lock().unwrap();
|
||||||
cache.insert(id, renderer.clone());
|
cache.insert(id, renderer.clone());
|
||||||
Some(renderer)
|
Some(renderer)
|
||||||
@@ -686,7 +687,7 @@ impl ControlPoint {
|
|||||||
server_id,
|
server_id,
|
||||||
container_ids,
|
container_ids,
|
||||||
} => {
|
} => {
|
||||||
let renderers_to_refresh: Vec<RendererId> = {
|
let renderers_to_refresh: Vec<ServiceId> = {
|
||||||
let mut bindings = bindings_for_media_worker.lock().unwrap();
|
let mut bindings = bindings_for_media_worker.lock().unwrap();
|
||||||
let mut to_refresh = Vec::new();
|
let mut to_refresh = Vec::new();
|
||||||
|
|
||||||
@@ -758,7 +759,7 @@ impl ControlPoint {
|
|||||||
thread::sleep(Duration::from_secs(60));
|
thread::sleep(Duration::from_secs(60));
|
||||||
|
|
||||||
// Collect all renderers with active bindings and mark them for refresh
|
// Collect all renderers with active bindings and mark them for refresh
|
||||||
let renderers_to_refresh: Vec<RendererId> = {
|
let renderers_to_refresh: Vec<ServiceId> = {
|
||||||
let mut bindings = bindings_for_periodic.lock().unwrap();
|
let mut bindings = bindings_for_periodic.lock().unwrap();
|
||||||
let mut to_refresh = Vec::new();
|
let mut to_refresh = Vec::new();
|
||||||
|
|
||||||
@@ -819,34 +820,32 @@ impl ControlPoint {
|
|||||||
|
|
||||||
infos
|
infos
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|info| UpnpRenderer::from_registry(info, &self.registry))
|
.map(|info| UpnpRenderer::from_info(&info))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the first renderer in the registry, if any.
|
/// Return the first renderer in the registry, if any.
|
||||||
pub fn default_upnp_renderer(&self) -> Option<UpnpRenderer> {
|
pub fn default_upnp_renderer(&self) -> Option<Arc<MusicRenderer>> {
|
||||||
let info = {
|
let info = {
|
||||||
let reg = self.registry.read().unwrap();
|
let reg = self.registry.read().unwrap();
|
||||||
reg.list_renderers().into_iter().next()
|
reg.list_renderers().into_iter().next()
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
Some(UpnpRenderer::from_registry(info, &self.registry))
|
Some(UpnpRenderer::from_info(&info))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lookup a renderer by id.
|
/// Lookup a renderer by id.
|
||||||
pub fn upnp_renderer_by_id(&self, id: &RendererId) -> Option<UpnpRenderer> {
|
pub fn renderer_by_id(&self, id: &ServiceId) -> Option<Arc<MusicRenderer>> {
|
||||||
let info = {
|
|
||||||
let reg = self.registry.read().unwrap();
|
let reg = self.registry.read().unwrap();
|
||||||
reg.get_renderer(id)
|
reg.get_renderer(id)
|
||||||
}?;
|
|
||||||
|
|
||||||
Some(UpnpRenderer::from_registry(info, &self.registry))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal helper to get or create a renderer from the cache.
|
/// Internal helper to get or create a renderer from the cache.
|
||||||
/// This ensures that Chromecast renderers maintain their persistent connections.
|
/// This ensures that Chromecast renderers maintain their persistent connections.
|
||||||
fn get_or_create_renderer(&self, info: RendererInfo) -> Option<MusicRenderer> {
|
fn get_or_create_renderer(&self, info: RendererInfo) -> Option<MusicRendererBackend> {
|
||||||
let id = info.id.clone();
|
let id = info.id();
|
||||||
|
|
||||||
// Try to get from cache first
|
// Try to get from cache first
|
||||||
{
|
{
|
||||||
@@ -857,7 +856,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not in cache, create new renderer
|
// Not in cache, create new renderer
|
||||||
if let Some(renderer) = MusicRenderer::from_registry_info(info, &self.registry) {
|
if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, &self.registry) {
|
||||||
// Add to cache
|
// Add to cache
|
||||||
let mut cache = self.renderer_cache.lock().unwrap();
|
let mut cache = self.renderer_cache.lock().unwrap();
|
||||||
cache.insert(id, renderer.clone());
|
cache.insert(id, renderer.clone());
|
||||||
@@ -868,7 +867,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot list of music renderers (protocol-agnostic view).
|
/// Snapshot list of music renderers (protocol-agnostic view).
|
||||||
pub fn list_music_renderers(&self) -> Vec<MusicRenderer> {
|
pub fn list_music_renderers(&self) -> Vec<MusicRendererBackend> {
|
||||||
let infos = {
|
let infos = {
|
||||||
let reg = self.registry.read().unwrap();
|
let reg = self.registry.read().unwrap();
|
||||||
reg.list_renderers()
|
reg.list_renderers()
|
||||||
@@ -876,7 +875,7 @@ impl ControlPoint {
|
|||||||
|
|
||||||
// Clean up cache - remove renderers that are no longer in the registry
|
// Clean up cache - remove renderers that are no longer in the registry
|
||||||
{
|
{
|
||||||
let current_ids: HashSet<RendererId> = infos.iter().map(|i| i.id.clone()).collect();
|
let current_ids: HashSet<ServiceId> = infos.iter().map(|i| i.id.clone()).collect();
|
||||||
let mut cache = self.renderer_cache.lock().unwrap();
|
let mut cache = self.renderer_cache.lock().unwrap();
|
||||||
cache.retain(|id, _| current_ids.contains(id));
|
cache.retain(|id, _| current_ids.contains(id));
|
||||||
}
|
}
|
||||||
@@ -888,7 +887,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return the first music renderer in the registry, if any.
|
/// Return the first music renderer in the registry, if any.
|
||||||
pub fn default_music_renderer(&self) -> Option<MusicRenderer> {
|
pub fn default_music_renderer(&self) -> Option<MusicRendererBackend> {
|
||||||
let infos = {
|
let infos = {
|
||||||
let reg = self.registry.read().unwrap();
|
let reg = self.registry.read().unwrap();
|
||||||
reg.list_renderers()
|
reg.list_renderers()
|
||||||
@@ -900,7 +899,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lookup a music renderer by id.
|
/// Lookup a music renderer by id.
|
||||||
pub fn music_renderer_by_id(&self, id: &RendererId) -> Option<MusicRenderer> {
|
pub fn music_renderer_by_id(&self, id: &ServiceId) -> Option<MusicRendererBackend> {
|
||||||
let info = {
|
let info = {
|
||||||
let reg = self.registry.read().unwrap();
|
let reg = self.registry.read().unwrap();
|
||||||
reg.get_renderer(id)
|
reg.get_renderer(id)
|
||||||
@@ -910,13 +909,13 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot list of media servers currently known by the registry.
|
/// Snapshot list of media servers currently known by the registry.
|
||||||
pub fn list_media_servers(&self) -> Vec<MediaServerInfo> {
|
pub fn list_media_servers(&self) -> Vec<UpnpMediaServer> {
|
||||||
let reg = self.registry.read().unwrap();
|
let reg = self.registry.read().unwrap();
|
||||||
reg.list_servers()
|
reg.list_servers()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lookup a media server by id.
|
/// Lookup a media server by id.
|
||||||
pub fn media_server(&self, id: &ServerId) -> Option<MediaServerInfo> {
|
pub fn media_server(&self, id: &ServerId) -> Option<UpnpMediaServer> {
|
||||||
let reg = self.registry.read().unwrap();
|
let reg = self.registry.read().unwrap();
|
||||||
reg.get_server(id)
|
reg.get_server(id)
|
||||||
}
|
}
|
||||||
@@ -928,7 +927,7 @@ impl ControlPoint {
|
|||||||
/// attachment stays consistent with the local `QueueBackend` snapshot.
|
/// attachment stays consistent with the local `QueueBackend` snapshot.
|
||||||
/// The actual structural change then goes through the backend helpers
|
/// The actual structural change then goes through the backend helpers
|
||||||
/// (`QueueBackend::clear_queue` via `RuntimeState::with_music_queue_mut`).
|
/// (`QueueBackend::clear_queue` via `RuntimeState::with_music_queue_mut`).
|
||||||
pub fn clear_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
pub fn clear_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> {
|
||||||
if !self.runtime.has_entry(renderer_id) {
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
let err = Self::runtime_entry_missing(renderer_id);
|
let err = Self::runtime_entry_missing(renderer_id);
|
||||||
warn!(
|
warn!(
|
||||||
@@ -981,7 +980,7 @@ impl ControlPoint {
|
|||||||
/// inside `RuntimeState::with_music_queue_mut`).
|
/// inside `RuntimeState::with_music_queue_mut`).
|
||||||
pub fn enqueue_items(
|
pub fn enqueue_items(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
if !self.runtime.has_entry(renderer_id) {
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
@@ -1026,7 +1025,7 @@ impl ControlPoint {
|
|||||||
/// view, prefer [`get_full_queue_snapshot`].
|
/// view, prefer [`get_full_queue_snapshot`].
|
||||||
pub fn get_queue_snapshot(
|
pub fn get_queue_snapshot(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
) -> anyhow::Result<Vec<PlaybackItem>> {
|
) -> anyhow::Result<Vec<PlaybackItem>> {
|
||||||
if !self.runtime.has_entry(renderer_id) {
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
let err = Self::runtime_entry_missing(renderer_id);
|
let err = Self::runtime_entry_missing(renderer_id);
|
||||||
@@ -1049,7 +1048,7 @@ impl ControlPoint {
|
|||||||
/// mutating the runtime.
|
/// mutating the runtime.
|
||||||
pub fn get_full_queue_snapshot(
|
pub fn get_full_queue_snapshot(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
) -> anyhow::Result<(Vec<PlaybackItem>, Option<usize>)> {
|
) -> anyhow::Result<(Vec<PlaybackItem>, Option<usize>)> {
|
||||||
if !self.runtime.has_entry(renderer_id) {
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
// Renderer not yet initialized in runtime (just discovered via SSDP)
|
// Renderer not yet initialized in runtime (just discovered via SSDP)
|
||||||
@@ -1070,7 +1069,7 @@ impl ControlPoint {
|
|||||||
///
|
///
|
||||||
/// Useful for UI layers that want to display the currently playing
|
/// Useful for UI layers that want to display the currently playing
|
||||||
/// track even when the renderer is not returning metadata via UPnP.
|
/// track even when the renderer is not returning metadata via UPnP.
|
||||||
pub fn get_current_track_metadata(&self, renderer_id: &RendererId) -> Option<TrackMetadata> {
|
pub fn get_current_track_metadata(&self, renderer_id: &ServiceId) -> Option<TrackMetadata> {
|
||||||
self.runtime.current_track_metadata(renderer_id)
|
self.runtime.current_track_metadata(renderer_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1080,7 +1079,7 @@ impl ControlPoint {
|
|||||||
/// or None if it doesn't (e.g., AVTransport).
|
/// or None if it doesn't (e.g., AVTransport).
|
||||||
pub fn get_renderer_queue_snapshot(
|
pub fn get_renderer_queue_snapshot(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
) -> anyhow::Result<Option<QueueSnapshot>> {
|
) -> anyhow::Result<Option<QueueSnapshot>> {
|
||||||
let renderer = self.music_renderer_by_id(renderer_id)
|
let renderer = self.music_renderer_by_id(renderer_id)
|
||||||
.ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?;
|
.ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?;
|
||||||
@@ -1090,14 +1089,14 @@ impl ControlPoint {
|
|||||||
/// Gets the length of the backend queue for renderers with persistent queues.
|
/// Gets the length of the backend queue for renderers with persistent queues.
|
||||||
///
|
///
|
||||||
/// Returns the queue length if the renderer has a backend queue, or 0 if it doesn't.
|
/// Returns the queue length if the renderer has a backend queue, or 0 if it doesn't.
|
||||||
pub fn get_renderer_queue_length(&self, renderer_id: &RendererId) -> anyhow::Result<usize> {
|
pub fn get_renderer_queue_length(&self, renderer_id: &ServiceId) -> anyhow::Result<usize> {
|
||||||
let snapshot = self.get_renderer_queue_snapshot(renderer_id)?;
|
let snapshot = self.get_renderer_queue_snapshot(renderer_id)?;
|
||||||
Ok(snapshot.map(|s| s.len()).unwrap_or(0))
|
Ok(snapshot.map(|s| s.len()).unwrap_or(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_cached_openhome_playlist_snapshot(
|
pub fn get_cached_openhome_playlist_snapshot(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
ttl: Duration,
|
ttl: Duration,
|
||||||
) -> anyhow::Result<OpenHomePlaylistSnapshot> {
|
) -> anyhow::Result<OpenHomePlaylistSnapshot> {
|
||||||
let renderer = self.openhome_renderer(renderer_id)?;
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
@@ -1108,7 +1107,7 @@ impl ControlPoint {
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub fn renderer_full_snapshot(
|
pub fn renderer_full_snapshot(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
) -> anyhow::Result<FullRendererSnapshot> {
|
) -> anyhow::Result<FullRendererSnapshot> {
|
||||||
let renderer = self
|
let renderer = self
|
||||||
.music_renderer_by_id(renderer_id)
|
.music_renderer_by_id(renderer_id)
|
||||||
@@ -1259,7 +1258,7 @@ impl ControlPoint {
|
|||||||
///
|
///
|
||||||
/// For renderers with persistent queues (OpenHome), this clears the queue on the renderer.
|
/// For renderers with persistent queues (OpenHome), this clears the queue on the renderer.
|
||||||
/// For other renderers, this returns an error.
|
/// For other renderers, this returns an error.
|
||||||
pub fn clear_renderer_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
pub fn clear_renderer_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> {
|
||||||
let renderer = self.music_renderer_by_id(renderer_id)
|
let renderer = self.music_renderer_by_id(renderer_id)
|
||||||
.ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?;
|
.ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?;
|
||||||
renderer.clear_queue()?;
|
renderer.clear_queue()?;
|
||||||
@@ -1274,7 +1273,7 @@ impl ControlPoint {
|
|||||||
/// Returns the backend-specific track ID if applicable.
|
/// Returns the backend-specific track ID if applicable.
|
||||||
pub fn add_track_to_renderer(
|
pub fn add_track_to_renderer(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
uri: &str,
|
uri: &str,
|
||||||
metadata: &str,
|
metadata: &str,
|
||||||
after_id: Option<u32>,
|
after_id: Option<u32>,
|
||||||
@@ -1293,7 +1292,7 @@ impl ControlPoint {
|
|||||||
/// For other renderers, this returns an error.
|
/// For other renderers, this returns an error.
|
||||||
pub fn select_renderer_track(
|
pub fn select_renderer_track(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
track_id: u32,
|
track_id: u32,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let renderer = self.music_renderer_by_id(renderer_id)
|
let renderer = self.music_renderer_by_id(renderer_id)
|
||||||
@@ -1310,7 +1309,7 @@ impl ControlPoint {
|
|||||||
/// The method only reads queue content via the runtime helpers and
|
/// The method only reads queue content via the runtime helpers and
|
||||||
/// delegates potential structural mutations to `QueueBackend` (when an item
|
/// delegates potential structural mutations to `QueueBackend` (when an item
|
||||||
/// needs to be restored after a playback error).
|
/// needs to be restored after a playback error).
|
||||||
pub fn play_current_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
pub fn play_current_from_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> {
|
||||||
if !self.runtime.has_entry(renderer_id) {
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
let err = Self::runtime_entry_missing(renderer_id);
|
let err = Self::runtime_entry_missing(renderer_id);
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1395,7 +1394,7 @@ impl ControlPoint {
|
|||||||
///
|
///
|
||||||
/// The structural mutation uses the `QueueBackend::dequeue_next` helper
|
/// The structural mutation uses the `QueueBackend::dequeue_next` helper
|
||||||
/// (through `RuntimeState`) so that all pointer updates are consistent.
|
/// (through `RuntimeState`) so that all pointer updates are consistent.
|
||||||
pub fn play_next_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
pub fn play_next_from_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> {
|
||||||
if !self.runtime.has_entry(renderer_id) {
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
let err = Self::runtime_entry_missing(renderer_id);
|
let err = Self::runtime_entry_missing(renderer_id);
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1527,7 +1526,7 @@ impl ControlPoint {
|
|||||||
///
|
///
|
||||||
/// For OpenHome renderers, this uses the playlist's native SeekId capability.
|
/// For OpenHome renderers, this uses the playlist's native SeekId capability.
|
||||||
/// For internal queues, this updates the current index and starts playback.
|
/// For internal queues, this updates the current index and starts playback.
|
||||||
pub fn play_queue_index(&self, renderer_id: &RendererId, index: usize) -> anyhow::Result<()> {
|
pub fn play_queue_index(&self, renderer_id: &ServiceId, index: usize) -> anyhow::Result<()> {
|
||||||
if !self.runtime.has_entry(renderer_id) {
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
let err = Self::runtime_entry_missing(renderer_id);
|
let err = Self::runtime_entry_missing(renderer_id);
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1643,7 +1642,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_queue_playback_if_idle(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
fn start_queue_playback_if_idle(&self, renderer_id: &ServiceId) -> anyhow::Result<()> {
|
||||||
let snapshot = self.runtime.snapshot_for(renderer_id);
|
let snapshot = self.runtime.snapshot_for(renderer_id);
|
||||||
let renderer_playing = matches!(snapshot.state, Some(PlaybackState::Playing));
|
let renderer_playing = matches!(snapshot.state, Some(PlaybackState::Playing));
|
||||||
let from_queue = self.runtime.is_playing_from_queue(renderer_id);
|
let from_queue = self.runtime.is_playing_from_queue(renderer_id);
|
||||||
@@ -1699,7 +1698,7 @@ impl ControlPoint {
|
|||||||
/// This method marks the stop as user-requested to prevent automatic
|
/// This method marks the stop as user-requested to prevent automatic
|
||||||
/// advancement to the next track in the queue when the STOPPED event
|
/// advancement to the next track in the queue when the STOPPED event
|
||||||
/// is received from the renderer.
|
/// is received from the renderer.
|
||||||
pub fn user_stop(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
pub fn user_stop(&self, renderer_id: &ServiceId) -> anyhow::Result<()> {
|
||||||
// Mark that user requested stop before actually stopping
|
// Mark that user requested stop before actually stopping
|
||||||
self.runtime.mark_user_stop_requested(renderer_id);
|
self.runtime.mark_user_stop_requested(renderer_id);
|
||||||
|
|
||||||
@@ -1746,7 +1745,7 @@ impl ControlPoint {
|
|||||||
/// The queue will be automatically refreshed when the playlist changes on the server.
|
/// The queue will be automatically refreshed when the playlist changes on the server.
|
||||||
pub fn attach_queue_to_playlist(
|
pub fn attach_queue_to_playlist(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
server_id: ServerId,
|
server_id: ServerId,
|
||||||
container_id: String,
|
container_id: String,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
@@ -1758,7 +1757,7 @@ impl ControlPoint {
|
|||||||
/// Same queue-mutation guarantees as [`attach_queue_to_playlist`].
|
/// Same queue-mutation guarantees as [`attach_queue_to_playlist`].
|
||||||
pub fn attach_queue_to_playlist_with_options(
|
pub fn attach_queue_to_playlist_with_options(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
server_id: ServerId,
|
server_id: ServerId,
|
||||||
container_id: String,
|
container_id: String,
|
||||||
auto_play: bool,
|
auto_play: bool,
|
||||||
@@ -1769,7 +1768,7 @@ impl ControlPoint {
|
|||||||
/// Internal implementation shared by every attach wrapper.
|
/// Internal implementation shared by every attach wrapper.
|
||||||
fn attach_queue_to_playlist_internal(
|
fn attach_queue_to_playlist_internal(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
server_id: &ServerId,
|
server_id: &ServerId,
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
auto_play: bool,
|
auto_play: bool,
|
||||||
@@ -1834,14 +1833,14 @@ impl ControlPoint {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// For initial attach with auto_play, force playback start (don't check if idle)
|
// For initial attach with auto_play, force playback start (don't check if idle)
|
||||||
let mut auto_start_cb = |rid: &RendererId| {
|
let mut auto_start_cb = |rid: &ServiceId| {
|
||||||
debug!(
|
debug!(
|
||||||
renderer = rid.0.as_str(),
|
renderer = rid.0.as_str(),
|
||||||
"Attach callback: forcing playback start (not checking if idle)"
|
"Attach callback: forcing playback start (not checking if idle)"
|
||||||
);
|
);
|
||||||
self.play_current_from_queue(rid)
|
self.play_current_from_queue(rid)
|
||||||
};
|
};
|
||||||
let callback: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>> = if auto_play {
|
let callback: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>> = if auto_play {
|
||||||
Some(&mut auto_start_cb)
|
Some(&mut auto_start_cb)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -1862,7 +1861,7 @@ impl ControlPoint {
|
|||||||
/// Public mutation API paired with `attach_queue_to_playlist*`. After calling
|
/// Public mutation API paired with `attach_queue_to_playlist*`. After calling
|
||||||
/// this, the queue will no longer be automatically refreshed from the server.
|
/// this, the queue will no longer be automatically refreshed from the server.
|
||||||
/// If no binding existed, this is a no-op.
|
/// If no binding existed, this is a no-op.
|
||||||
pub fn detach_queue_playlist(&self, renderer_id: &RendererId) {
|
pub fn detach_queue_playlist(&self, renderer_id: &ServiceId) {
|
||||||
self.detach_playlist_binding(renderer_id, "api_detach");
|
self.detach_playlist_binding(renderer_id, "api_detach");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1872,7 +1871,7 @@ impl ControlPoint {
|
|||||||
/// bound to a server playlist container, or `None` otherwise.
|
/// bound to a server playlist container, or `None` otherwise.
|
||||||
pub fn current_queue_playlist_binding(
|
pub fn current_queue_playlist_binding(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
) -> Option<(ServerId, String, bool)> {
|
) -> Option<(ServerId, String, bool)> {
|
||||||
let bindings = self.playlist_bindings.lock().unwrap();
|
let bindings = self.playlist_bindings.lock().unwrap();
|
||||||
bindings.get(renderer_id).map(|binding| {
|
bindings.get(renderer_id).map(|binding| {
|
||||||
@@ -1888,7 +1887,7 @@ impl ControlPoint {
|
|||||||
///
|
///
|
||||||
/// Invariant: every user-driven queue mutation **must** call this method so
|
/// Invariant: every user-driven queue mutation **must** call this method so
|
||||||
/// that bindings never become out of sync with the local queue snapshot.
|
/// that bindings never become out of sync with the local queue snapshot.
|
||||||
fn detach_playlist_binding(&self, renderer_id: &RendererId, reason: &str) {
|
fn detach_playlist_binding(&self, renderer_id: &ServiceId, reason: &str) {
|
||||||
let removed = {
|
let removed = {
|
||||||
let mut bindings = self.playlist_bindings.lock().unwrap();
|
let mut bindings = self.playlist_bindings.lock().unwrap();
|
||||||
bindings.remove(renderer_id)
|
bindings.remove(renderer_id)
|
||||||
@@ -1956,14 +1955,14 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_entry_missing(renderer_id: &RendererId) -> anyhow::Error {
|
fn runtime_entry_missing(renderer_id: &ServiceId) -> anyhow::Error {
|
||||||
anyhow!(
|
anyhow!(
|
||||||
"Renderer {} not registered in control point runtime",
|
"Renderer {} not registered in control point runtime",
|
||||||
renderer_id.0
|
renderer_id.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn openhome_renderer(&self, renderer_id: &RendererId) -> anyhow::Result<MusicRenderer> {
|
fn openhome_renderer(&self, renderer_id: &ServiceId) -> anyhow::Result<MusicRendererBackend> {
|
||||||
let renderer = self
|
let renderer = self
|
||||||
.music_renderer_by_id(renderer_id)
|
.music_renderer_by_id(renderer_id)
|
||||||
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?;
|
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?;
|
||||||
@@ -1973,7 +1972,7 @@ impl ControlPoint {
|
|||||||
Ok(renderer)
|
Ok(renderer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_openhome_playlist_for(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
fn sync_openhome_playlist_for(&self, renderer_id: &ServiceId) -> anyhow::Result<()> {
|
||||||
sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id)
|
sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2070,17 +2069,17 @@ struct OpenHomePlaylistCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct RuntimeState {
|
struct RuntimeState {
|
||||||
entries: Mutex<HashMap<RendererId, RendererRuntimeEntry>>,
|
entries: Mutex<HashMap<ServiceId, RendererRuntimeEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RendererRuntimeStateMut<'a> {
|
pub struct RendererRuntimeStateMut<'a> {
|
||||||
pub queue: MusicQueueGuard<'a>,
|
pub queue: MusicQueueGuard<'a>,
|
||||||
_guard: MutexGuard<'a, HashMap<RendererId, RendererRuntimeEntry>>,
|
_guard: MutexGuard<'a, HashMap<ServiceId, RendererRuntimeEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> RendererRuntimeStateMut<'a> {
|
impl<'a> RendererRuntimeStateMut<'a> {
|
||||||
fn new(
|
fn new(
|
||||||
guard: MutexGuard<'a, HashMap<RendererId, RendererRuntimeEntry>>,
|
guard: MutexGuard<'a, HashMap<ServiceId, RendererRuntimeEntry>>,
|
||||||
queue_ptr: *mut MusicQueue,
|
queue_ptr: *mut MusicQueue,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -2122,23 +2121,23 @@ struct RuntimeOpenHomeQueueProvider {
|
|||||||
runtime: Arc<RuntimeState>,
|
runtime: Arc<RuntimeState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenHomeQueueProvider for RuntimeOpenHomeQueueProvider {
|
// impl OpenHomeQueueProvider for RuntimeOpenHomeQueueProvider {
|
||||||
fn renderer_state(&self, renderer_id: &RendererId) -> anyhow::Result<RendererRuntimeState> {
|
// fn renderer_state(&self, renderer_id: &RendererId) -> anyhow::Result<RendererRuntimeState> {
|
||||||
self.runtime.renderer_state(renderer_id)
|
// self.runtime.renderer_state(renderer_id)
|
||||||
}
|
// }
|
||||||
|
|
||||||
fn renderer_state_mut<'a>(
|
// fn renderer_state_mut<'a>(
|
||||||
&'a self,
|
// &'a self,
|
||||||
renderer_id: &RendererId,
|
// renderer_id: &RendererId,
|
||||||
) -> anyhow::Result<RendererRuntimeStateMut<'a>> {
|
// ) -> anyhow::Result<RendererRuntimeStateMut<'a>> {
|
||||||
self.runtime.renderer_state_mut(renderer_id)
|
// self.runtime.renderer_state_mut(renderer_id)
|
||||||
}
|
// }
|
||||||
|
|
||||||
fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
// fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
||||||
self.runtime.invalidate_openhome_cache(renderer_id);
|
// self.runtime.invalidate_openhome_cache(renderer_id);
|
||||||
Ok(())
|
// Ok(())
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
impl RuntimeState {
|
impl RuntimeState {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
@@ -2147,7 +2146,7 @@ impl RuntimeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot_for(&self, id: &RendererId) -> RendererRuntimeSnapshot {
|
fn snapshot_for(&self, id: &ServiceId) -> RendererRuntimeSnapshot {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
entries
|
entries
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -2155,13 +2154,13 @@ impl RuntimeState {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_snapshot(&self, id: &RendererId, snapshot: RendererRuntimeSnapshot) {
|
fn update_snapshot(&self, id: &ServiceId, snapshot: RendererRuntimeSnapshot) {
|
||||||
self.with_entry(id, |entry| {
|
self.with_entry(id, |entry| {
|
||||||
entry.snapshot = snapshot;
|
entry.snapshot = snapshot;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_snapshot_with<F>(&self, id: &RendererId, f: F)
|
fn update_snapshot_with<F>(&self, id: &ServiceId, f: F)
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut RendererRuntimeSnapshot),
|
F: FnOnce(&mut RendererRuntimeSnapshot),
|
||||||
{
|
{
|
||||||
@@ -2170,12 +2169,12 @@ impl RuntimeState {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_entry(&self, id: &RendererId) -> bool {
|
fn has_entry(&self, id: &ServiceId) -> bool {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
entries.contains_key(id)
|
entries.contains_key(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_music_queue_mut<F, R>(&self, id: &RendererId, f: F) -> anyhow::Result<R>
|
fn with_music_queue_mut<F, R>(&self, id: &ServiceId, f: F) -> anyhow::Result<R>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut MusicQueue) -> anyhow::Result<R>,
|
F: FnOnce(&mut MusicQueue) -> anyhow::Result<R>,
|
||||||
{
|
{
|
||||||
@@ -2186,14 +2185,14 @@ impl RuntimeState {
|
|||||||
f(&mut entry.queue)
|
f(&mut entry.queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_snapshot(&self, id: &RendererId) -> Option<Vec<PlaybackItem>> {
|
fn queue_snapshot(&self, id: &ServiceId) -> Option<Vec<PlaybackItem>> {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
entries
|
entries
|
||||||
.get(id)
|
.get(id)
|
||||||
.and_then(|entry| entry.queue.upcoming_items().ok())
|
.and_then(|entry| entry.queue.upcoming_items().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_full_snapshot(&self, id: &RendererId) -> Option<(Vec<PlaybackItem>, Option<usize>)> {
|
fn queue_full_snapshot(&self, id: &ServiceId) -> Option<(Vec<PlaybackItem>, Option<usize>)> {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
entries.get(id).and_then(|entry| {
|
entries.get(id).and_then(|entry| {
|
||||||
entry
|
entry
|
||||||
@@ -2204,17 +2203,17 @@ impl RuntimeState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderer_state(&self, id: &RendererId) -> anyhow::Result<RendererRuntimeState> {
|
// fn renderer_state(&self, id: &RendererId) -> anyhow::Result<RendererRuntimeState> {
|
||||||
let entries = self.entries.lock().unwrap();
|
// let entries = self.entries.lock().unwrap();
|
||||||
let entry = entries
|
// let entry = entries
|
||||||
.get(id)
|
// .get(id)
|
||||||
.ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?;
|
// .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?;
|
||||||
Ok(RendererRuntimeState {
|
// Ok(RendererRuntimeState {
|
||||||
queue: entry.queue.clone(),
|
// queue: entry.queue.clone(),
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
fn renderer_state_mut(&self, id: &RendererId) -> anyhow::Result<RendererRuntimeStateMut<'_>> {
|
fn renderer_state_mut(&self, id: &ServiceId) -> anyhow::Result<RendererRuntimeStateMut<'_>> {
|
||||||
let mut entries = self.entries.lock().unwrap();
|
let mut entries = self.entries.lock().unwrap();
|
||||||
let queue_ptr = {
|
let queue_ptr = {
|
||||||
let entry = entries
|
let entry = entries
|
||||||
@@ -2226,7 +2225,7 @@ impl RuntimeState {
|
|||||||
Ok(RendererRuntimeStateMut::new(entries, queue_ptr))
|
Ok(RendererRuntimeStateMut::new(entries, queue_ptr))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_track_metadata(&self, id: &RendererId) -> Option<TrackMetadata> {
|
fn current_track_metadata(&self, id: &ServiceId) -> Option<TrackMetadata> {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
entries
|
entries
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -2236,7 +2235,7 @@ impl RuntimeState {
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
fn renderer_snapshot_bundle(
|
fn renderer_snapshot_bundle(
|
||||||
&self,
|
&self,
|
||||||
id: &RendererId,
|
id: &ServiceId,
|
||||||
) -> (RendererRuntimeSnapshot, Vec<PlaybackItem>, Option<usize>) {
|
) -> (RendererRuntimeSnapshot, Vec<PlaybackItem>, Option<usize>) {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
if let Some(entry) = entries.get(id) {
|
if let Some(entry) = entries.get(id) {
|
||||||
@@ -2260,26 +2259,26 @@ impl RuntimeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> {
|
fn dequeue_next(&self, id: &ServiceId) -> Option<(PlaybackItem, usize)> {
|
||||||
let mut entries = self.entries.lock().unwrap();
|
let mut entries = self.entries.lock().unwrap();
|
||||||
let entry = entries.get_mut(id)?;
|
let entry = entries.get_mut(id)?;
|
||||||
entry.queue.dequeue_next().ok().flatten()
|
entry.queue.dequeue_next().ok().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn peek_current(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> {
|
fn peek_current(&self, id: &ServiceId) -> Option<(PlaybackItem, usize)> {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
let entry = entries.get(id)?;
|
let entry = entries.get(id)?;
|
||||||
entry.queue.peek_current().ok().flatten()
|
entry.queue.peek_current().ok().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_playback_source(&self, id: &RendererId, source: PlaybackSource) {
|
fn set_playback_source(&self, id: &ServiceId, source: PlaybackSource) {
|
||||||
let mut entries = self.entries.lock().unwrap();
|
let mut entries = self.entries.lock().unwrap();
|
||||||
if let Some(entry) = entries.get_mut(id) {
|
if let Some(entry) = entries.get_mut(id) {
|
||||||
entry.playback_source = source;
|
entry.playback_source = source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playback_source(&self, id: &RendererId) -> PlaybackSource {
|
fn playback_source(&self, id: &ServiceId) -> PlaybackSource {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
entries
|
entries
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -2287,11 +2286,11 @@ impl RuntimeState {
|
|||||||
.unwrap_or(PlaybackSource::None)
|
.unwrap_or(PlaybackSource::None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_playing_from_queue(&self, id: &RendererId) -> bool {
|
fn is_playing_from_queue(&self, id: &ServiceId) -> bool {
|
||||||
matches!(self.playback_source(id), PlaybackSource::FromQueue)
|
matches!(self.playback_source(id), PlaybackSource::FromQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_external_if_idle(&self, id: &RendererId) {
|
fn mark_external_if_idle(&self, id: &ServiceId) {
|
||||||
let mut entries = self.entries.lock().unwrap();
|
let mut entries = self.entries.lock().unwrap();
|
||||||
if let Some(entry) = entries.get_mut(id) {
|
if let Some(entry) = entries.get_mut(id) {
|
||||||
if matches!(entry.playback_source, PlaybackSource::None) {
|
if matches!(entry.playback_source, PlaybackSource::None) {
|
||||||
@@ -2300,7 +2299,7 @@ impl RuntimeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_entry<F, R>(&self, id: &RendererId, f: F) -> R
|
fn with_entry<F, R>(&self, id: &ServiceId, f: F) -> R
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut RendererRuntimeEntry) -> R,
|
F: FnOnce(&mut RendererRuntimeEntry) -> R,
|
||||||
{
|
{
|
||||||
@@ -2311,24 +2310,24 @@ impl RuntimeState {
|
|||||||
f(entry)
|
f(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_music_queue(&self, id: &RendererId, queue: MusicQueue) {
|
fn set_music_queue(&self, id: &ServiceId, queue: MusicQueue) {
|
||||||
self.with_entry(id, |entry| {
|
self.with_entry(id, |entry| {
|
||||||
entry.queue = queue;
|
entry.queue = queue;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invalidate_openhome_cache(&self, id: &RendererId) {
|
fn invalidate_openhome_cache(&self, id: &ServiceId) {
|
||||||
self.with_entry(id, |entry| {
|
self.with_entry(id, |entry| {
|
||||||
entry.openhome_cache = OpenHomePlaylistCache::default();
|
entry.openhome_cache = OpenHomePlaylistCache::default();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_openhome_cache(&self, id: &RendererId) -> Option<OpenHomePlaylistCache> {
|
fn get_openhome_cache(&self, id: &ServiceId) -> Option<OpenHomePlaylistCache> {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
entries.get(id).map(|entry| entry.openhome_cache.clone())
|
entries.get(id).map(|entry| entry.openhome_cache.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_openhome_cache(&self, id: &RendererId, cache: OpenHomePlaylistCache) {
|
fn set_openhome_cache(&self, id: &ServiceId, cache: OpenHomePlaylistCache) {
|
||||||
self.with_entry(id, |entry| {
|
self.with_entry(id, |entry| {
|
||||||
entry.openhome_cache = cache;
|
entry.openhome_cache = cache;
|
||||||
});
|
});
|
||||||
@@ -2336,7 +2335,7 @@ impl RuntimeState {
|
|||||||
|
|
||||||
fn openhome_snapshot_cached(
|
fn openhome_snapshot_cached(
|
||||||
&self,
|
&self,
|
||||||
renderer: &MusicRenderer,
|
renderer: &MusicRendererBackend,
|
||||||
ttl: Duration,
|
ttl: Duration,
|
||||||
) -> anyhow::Result<OpenHomePlaylistSnapshot> {
|
) -> anyhow::Result<OpenHomePlaylistSnapshot> {
|
||||||
let ids = renderer.openhome_playlist_ids()?;
|
let ids = renderer.openhome_playlist_ids()?;
|
||||||
@@ -2352,7 +2351,7 @@ impl RuntimeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = renderer.fetch_openhome_playlist_snapshot()?;
|
let snapshot = renderer.openhome_playlist_snapshot()?;
|
||||||
let cache = OpenHomePlaylistCache {
|
let cache = OpenHomePlaylistCache {
|
||||||
ids: Some(ids),
|
ids: Some(ids),
|
||||||
snapshot: Some(snapshot.clone()),
|
snapshot: Some(snapshot.clone()),
|
||||||
@@ -2362,13 +2361,13 @@ impl RuntimeState {
|
|||||||
Ok(snapshot)
|
Ok(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_playlist_backend(&self, id: &RendererId, backend: PlaylistBackend) {
|
fn set_playlist_backend(&self, id: &ServiceId, backend: PlaylistBackend) {
|
||||||
self.with_entry(id, |entry| {
|
self.with_entry(id, |entry| {
|
||||||
entry.playlist_backend = backend;
|
entry.playlist_backend = backend;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playlist_backend(&self, id: &RendererId) -> PlaylistBackend {
|
fn playlist_backend(&self, id: &ServiceId) -> PlaylistBackend {
|
||||||
let entries = self.entries.lock().unwrap();
|
let entries = self.entries.lock().unwrap();
|
||||||
entries
|
entries
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -2376,18 +2375,18 @@ impl RuntimeState {
|
|||||||
.unwrap_or(PlaylistBackend::PMOQueue)
|
.unwrap_or(PlaylistBackend::PMOQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uses_openhome_playlist(&self, id: &RendererId) -> bool {
|
fn uses_openhome_playlist(&self, id: &ServiceId) -> bool {
|
||||||
matches!(self.playlist_backend(id), PlaylistBackend::OpenHome)
|
matches!(self.playlist_backend(id), PlaylistBackend::OpenHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_user_stop_requested(&self, id: &RendererId) {
|
fn mark_user_stop_requested(&self, id: &ServiceId) {
|
||||||
let mut entries = self.entries.lock().unwrap();
|
let mut entries = self.entries.lock().unwrap();
|
||||||
if let Some(entry) = entries.get_mut(id) {
|
if let Some(entry) = entries.get_mut(id) {
|
||||||
entry.user_stop_requested = true;
|
entry.user_stop_requested = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_and_clear_user_stop_requested(&self, id: &RendererId) -> bool {
|
fn check_and_clear_user_stop_requested(&self, id: &ServiceId) -> bool {
|
||||||
let mut entries = self.entries.lock().unwrap();
|
let mut entries = self.entries.lock().unwrap();
|
||||||
if let Some(entry) = entries.get_mut(id) {
|
if let Some(entry) = entries.get_mut(id) {
|
||||||
let was_requested = entry.user_stop_requested;
|
let was_requested = entry.user_stop_requested;
|
||||||
@@ -2408,10 +2407,10 @@ impl RuntimeState {
|
|||||||
fn refresh_attached_queue_for(
|
fn refresh_attached_queue_for(
|
||||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||||
runtime: &Arc<RuntimeState>,
|
runtime: &Arc<RuntimeState>,
|
||||||
bindings: &Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
bindings: &Arc<Mutex<HashMap<ServiceId, PlaylistBinding>>>,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
event_bus: &RendererEventBus,
|
event_bus: &RendererEventBus,
|
||||||
mut after_refresh: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>>,
|
mut after_refresh: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
// Step 1: Check binding and mark refresh as in-progress
|
// Step 1: Check binding and mark refresh as in-progress
|
||||||
let (server_id, container_id, auto_play) = {
|
let (server_id, container_id, auto_play) = {
|
||||||
@@ -2483,7 +2482,7 @@ fn refresh_attached_queue_for(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Create MusicServer and browse container
|
// Step 3: Create MusicServer and browse container
|
||||||
let music_server = MusicServer::from_info(&server_info, Duration::from_secs(5))?;
|
let music_server = UpnpMediaServer::from_info(&server_info, Duration::from_secs(5))?;
|
||||||
|
|
||||||
const MAX_BROWSE_ATTEMPTS: usize = 3;
|
const MAX_BROWSE_ATTEMPTS: usize = 3;
|
||||||
const BROWSE_RETRY_DELAY_MS: u64 = 200;
|
const BROWSE_RETRY_DELAY_MS: u64 = 200;
|
||||||
@@ -2579,7 +2578,7 @@ fn refresh_attached_queue_for(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(info) = renderer_info {
|
if let Some(info) = renderer_info {
|
||||||
if let Some(renderer) = MusicRenderer::from_registry_info(info, registry) {
|
if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, registry) {
|
||||||
// Try direct query first
|
// Try direct query first
|
||||||
if matches!(renderer.playback_state(), Ok(PlaybackState::Playing)) {
|
if matches!(renderer.playback_state(), Ok(PlaybackState::Playing)) {
|
||||||
true
|
true
|
||||||
@@ -2731,7 +2730,7 @@ fn refresh_attached_queue_for(
|
|||||||
const OPENHOME_TRACK_PREFIX: &str = "openhome:";
|
const OPENHOME_TRACK_PREFIX: &str = "openhome:";
|
||||||
|
|
||||||
fn playback_item_from_openhome_track(
|
fn playback_item_from_openhome_track(
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
track: &OpenHomePlaylistTrack,
|
track: &OpenHomePlaylistTrack,
|
||||||
) -> PlaybackItem {
|
) -> PlaybackItem {
|
||||||
let metadata = TrackMetadata {
|
let metadata = TrackMetadata {
|
||||||
@@ -2833,16 +2832,16 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata {
|
|||||||
|
|
||||||
fn openhome_renderer_from_registry(
|
fn openhome_renderer_from_registry(
|
||||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
) -> anyhow::Result<MusicRenderer> {
|
) -> anyhow::Result<MusicRendererBackend> {
|
||||||
let info = {
|
let info = {
|
||||||
let reg = registry.read().unwrap();
|
let reg = registry.read().unwrap();
|
||||||
reg.get_renderer(renderer_id)
|
reg.get_renderer(renderer_id)
|
||||||
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?
|
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?
|
||||||
};
|
};
|
||||||
let renderer = MusicRenderer::from_registry_info(info, registry)
|
let renderer = MusicRendererBackend::from_renderer_info(info, registry)
|
||||||
.and_then(|r| match r {
|
.and_then(|r| match r {
|
||||||
MusicRenderer::OpenHome(_) => Some(r),
|
MusicRendererBackend::OpenHome(_) => Some(r),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))?;
|
.ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))?;
|
||||||
@@ -2853,11 +2852,11 @@ fn sync_openhome_playlist(
|
|||||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||||
runtime: &Arc<RuntimeState>,
|
runtime: &Arc<RuntimeState>,
|
||||||
event_bus: &RendererEventBus,
|
event_bus: &RendererEventBus,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let renderer = openhome_renderer_from_registry(registry, renderer_id)?;
|
let renderer = openhome_renderer_from_registry(registry, renderer_id)?;
|
||||||
|
|
||||||
let snapshot = renderer.fetch_openhome_playlist_snapshot()?;
|
let snapshot = renderer.openhome_playlist_snapshot()?;
|
||||||
let playback_items: Vec<PlaybackItem> = snapshot
|
let playback_items: Vec<PlaybackItem> = snapshot
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2910,7 +2909,7 @@ fn spawn_openhome_event_runtime(
|
|||||||
runtime: Arc<RuntimeState>,
|
runtime: Arc<RuntimeState>,
|
||||||
event_bus: RendererEventBus,
|
event_bus: RendererEventBus,
|
||||||
event_tx: Sender<RendererEvent>,
|
event_tx: Sender<RendererEvent>,
|
||||||
playlist_bindings: Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
playlist_bindings: Arc<Mutex<HashMap<ServiceId, PlaylistBinding>>>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let listener = TcpListener::bind("0.0.0.0:0")?;
|
let listener = TcpListener::bind("0.0.0.0:0")?;
|
||||||
let listener_addr = listener
|
let listener_addr = listener
|
||||||
@@ -2950,7 +2949,7 @@ struct OpenHomeEventRuntime {
|
|||||||
http_timeout: Duration,
|
http_timeout: Duration,
|
||||||
subscriptions: HashMap<OpenHomeSubscriptionKey, OpenHomeSubscriptionState>,
|
subscriptions: HashMap<OpenHomeSubscriptionKey, OpenHomeSubscriptionState>,
|
||||||
path_index: HashMap<String, OpenHomeSubscriptionKey>,
|
path_index: HashMap<String, OpenHomeSubscriptionKey>,
|
||||||
playlist_bindings: Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
playlist_bindings: Arc<Mutex<HashMap<ServiceId, PlaylistBinding>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenHomeEventRuntime {
|
impl OpenHomeEventRuntime {
|
||||||
@@ -2961,7 +2960,7 @@ impl OpenHomeEventRuntime {
|
|||||||
notify_rx: Receiver<OpenHomeIncomingNotify>,
|
notify_rx: Receiver<OpenHomeIncomingNotify>,
|
||||||
event_tx: Sender<RendererEvent>,
|
event_tx: Sender<RendererEvent>,
|
||||||
listener_port: u16,
|
listener_port: u16,
|
||||||
playlist_bindings: Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
playlist_bindings: Arc<Mutex<HashMap<ServiceId, PlaylistBinding>>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
registry,
|
registry,
|
||||||
@@ -3172,7 +3171,7 @@ impl OpenHomeEventRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_info_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) {
|
fn handle_info_properties(&self, renderer_id: &ServiceId, properties: Vec<(String, String)>) {
|
||||||
let mut metadata_xml: Option<String> = None;
|
let mut metadata_xml: Option<String> = None;
|
||||||
let mut transport_state: Option<String> = None;
|
let mut transport_state: Option<String> = None;
|
||||||
let mut track_id: Option<u32> = None;
|
let mut track_id: Option<u32> = None;
|
||||||
@@ -3245,7 +3244,7 @@ impl OpenHomeEventRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_time_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) {
|
fn handle_time_properties(&self, renderer_id: &ServiceId, properties: Vec<(String, String)>) {
|
||||||
let mut duration: Option<u32> = None;
|
let mut duration: Option<u32> = None;
|
||||||
let mut seconds: Option<u32> = None;
|
let mut seconds: Option<u32> = None;
|
||||||
|
|
||||||
@@ -3422,12 +3421,12 @@ impl OpenHomeEventRuntime {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
struct OpenHomeSubscriptionKey {
|
struct OpenHomeSubscriptionKey {
|
||||||
renderer_id: RendererId,
|
renderer_id: ServiceId,
|
||||||
service: OhServiceKind,
|
service: OhServiceKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenHomeSubscriptionKey {
|
impl OpenHomeSubscriptionKey {
|
||||||
fn new(renderer_id: &RendererId, service: OhServiceKind) -> Self {
|
fn new(renderer_id: &ServiceId, service: OhServiceKind) -> Self {
|
||||||
Self {
|
Self {
|
||||||
renderer_id: renderer_id.clone(),
|
renderer_id: renderer_id.clone(),
|
||||||
service,
|
service,
|
||||||
@@ -3607,7 +3606,7 @@ fn write_openhome_http_response(
|
|||||||
stream.write_all(response.as_bytes())
|
stream.write_all(response.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_openhome_callback_path(id: &RendererId, service: OhServiceKind) -> String {
|
fn build_openhome_callback_path(id: &ServiceId, service: OhServiceKind) -> String {
|
||||||
let mut sanitized = String::new();
|
let mut sanitized = String::new();
|
||||||
for ch in id.0.chars() {
|
for ch in id.0.chars() {
|
||||||
if ch.is_ascii_alphanumeric() {
|
if ch.is_ascii_alphanumeric() {
|
||||||
@@ -3624,7 +3623,7 @@ fn build_openhome_callback_path(id: &RendererId, service: OhServiceKind) -> Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_openhome_propertyset(
|
fn parse_openhome_propertyset(
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
service: &OhServiceKind,
|
service: &OhServiceKind,
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
) -> Vec<(String, String)> {
|
) -> Vec<(String, String)> {
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
use pmoupnp::ssdp::SsdpEvent;
|
|
||||||
|
|
||||||
use crate::media_server::MediaServerInfo;
|
|
||||||
use crate::model::RendererInfo;
|
|
||||||
use crate::registry::DeviceUpdate;
|
|
||||||
|
|
||||||
/// État connu pour un endpoint UPnP identifié par son UDN.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct DiscoveredEndpoint {
|
|
||||||
/// UDN normalisé (ex: "uuid:xxxx", en minuscules).
|
|
||||||
pub udn: String,
|
|
||||||
/// Dernière URL de description device (LOCATION SSDP).
|
|
||||||
pub location: String,
|
|
||||||
/// Dernier header SERVER vu sur cet endpoint.
|
|
||||||
pub server_header: String,
|
|
||||||
/// Dernier max-age indiqué (TTL SSDP).
|
|
||||||
pub max_age: u32,
|
|
||||||
/// Date de dernière vue (Now lors du dernier Alive ou SearchResponse).
|
|
||||||
pub last_seen: SystemTime,
|
|
||||||
/// Indique si on a vu ce endpoint comme MediaRenderer.
|
|
||||||
pub seen_as_renderer: bool,
|
|
||||||
/// Indique si on a vu ce endpoint comme MediaServer.
|
|
||||||
pub seen_as_server: bool,
|
|
||||||
/// ST/NT vus (pour debug/diagnostic si utile).
|
|
||||||
pub types_seen: HashSet<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiscoveredEndpoint {
|
|
||||||
pub fn new(udn: String, location: String, server_header: String, max_age: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
udn,
|
|
||||||
location,
|
|
||||||
server_header,
|
|
||||||
max_age,
|
|
||||||
last_seen: SystemTime::now(),
|
|
||||||
seen_as_renderer: false,
|
|
||||||
seen_as_server: false,
|
|
||||||
types_seen: HashSet::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn touch(&mut self, location: String, server_header: String, max_age: u32) {
|
|
||||||
self.location = location;
|
|
||||||
self.server_header = server_header;
|
|
||||||
self.max_age = max_age;
|
|
||||||
self.last_seen = SystemTime::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fournit les descriptions haut niveau à partir d’un endpoint découvert.
|
|
||||||
/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location`
|
|
||||||
/// et parser la description pour remplir RendererInfo / MediaServerInfo.
|
|
||||||
pub trait DeviceDescriptionProvider: Send + Sync {
|
|
||||||
/// Construit un RendererInfo pour cet endpoint, ou None s’il
|
|
||||||
/// ne correspond pas à un renderer audio intéressant.
|
|
||||||
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo>;
|
|
||||||
|
|
||||||
/// Construit un MediaServerInfo pour cet endpoint, ou None s’il
|
|
||||||
/// ne correspond pas à un media server (ou pas intéressant).
|
|
||||||
fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option<MediaServerInfo>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
|
||||||
pub struct DiscoveryManager<P>
|
|
||||||
where
|
|
||||||
P: DeviceDescriptionProvider,
|
|
||||||
{
|
|
||||||
endpoints: HashMap<String, DiscoveredEndpoint>,
|
|
||||||
provider: P,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<P> DiscoveryManager<P>
|
|
||||||
where
|
|
||||||
P: DeviceDescriptionProvider,
|
|
||||||
{
|
|
||||||
pub fn new(provider: P) -> Self {
|
|
||||||
Self {
|
|
||||||
endpoints: HashMap::new(),
|
|
||||||
provider,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec<DeviceUpdate> {
|
|
||||||
let mut updates = Vec::new();
|
|
||||||
|
|
||||||
match event {
|
|
||||||
SsdpEvent::Alive {
|
|
||||||
usn,
|
|
||||||
nt,
|
|
||||||
location,
|
|
||||||
server,
|
|
||||||
max_age,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
|
||||||
self.handle_alive(udn, nt, location, server, max_age, &mut updates);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SsdpEvent::SearchResponse {
|
|
||||||
usn,
|
|
||||||
st,
|
|
||||||
location,
|
|
||||||
server,
|
|
||||||
max_age,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
|
||||||
self.handle_search_response(udn, st, location, server, max_age, &mut updates);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SsdpEvent::ByeBye { usn, nt, .. } => {
|
|
||||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
|
||||||
self.handle_byebye(udn, nt, &mut updates);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updates
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_alive(
|
|
||||||
&mut self,
|
|
||||||
udn: String,
|
|
||||||
nt: String,
|
|
||||||
location: String,
|
|
||||||
server_header: String,
|
|
||||||
max_age: u32,
|
|
||||||
updates: &mut Vec<DeviceUpdate>,
|
|
||||||
) {
|
|
||||||
self.update_endpoint(udn, nt, location, server_header, max_age, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_search_response(
|
|
||||||
&mut self,
|
|
||||||
udn: String,
|
|
||||||
st: String,
|
|
||||||
location: String,
|
|
||||||
server_header: String,
|
|
||||||
max_age: u32,
|
|
||||||
updates: &mut Vec<DeviceUpdate>,
|
|
||||||
) {
|
|
||||||
self.update_endpoint(udn, st, location, server_header, max_age, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec<DeviceUpdate>) {
|
|
||||||
if let Some(endpoint) = self.endpoints.get(&udn) {
|
|
||||||
if endpoint.seen_as_renderer {
|
|
||||||
updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone()));
|
|
||||||
}
|
|
||||||
if endpoint.seen_as_server {
|
|
||||||
updates.push(DeviceUpdate::ServerOfflineByUdn(udn));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_endpoint(
|
|
||||||
&mut self,
|
|
||||||
udn: String,
|
|
||||||
device_type: String,
|
|
||||||
location: String,
|
|
||||||
server_header: String,
|
|
||||||
max_age: u32,
|
|
||||||
updates: &mut Vec<DeviceUpdate>,
|
|
||||||
) {
|
|
||||||
tracing::debug!(
|
|
||||||
"SSDP update: udn={} type={} location={} max_age={}",
|
|
||||||
udn,
|
|
||||||
device_type,
|
|
||||||
location,
|
|
||||||
max_age
|
|
||||||
);
|
|
||||||
|
|
||||||
let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({
|
|
||||||
let udn = udn.clone();
|
|
||||||
let location = location.clone();
|
|
||||||
let server_header = server_header.clone();
|
|
||||||
move || DiscoveredEndpoint::new(udn, location, server_header, max_age)
|
|
||||||
});
|
|
||||||
|
|
||||||
endpoint.touch(location, server_header, max_age);
|
|
||||||
endpoint.types_seen.insert(device_type);
|
|
||||||
|
|
||||||
// Toujours tenter de classifier et envoyer un événement Online pour maintenir
|
|
||||||
// l'état à jour dans le registre (important pour les serveurs qui ré-apparaissent)
|
|
||||||
if let Some(info) = self.provider.build_renderer_info(endpoint) {
|
|
||||||
if !endpoint.seen_as_renderer {
|
|
||||||
tracing::debug!(
|
|
||||||
"Renderer classified: udn={} friendly_name={} model={}",
|
|
||||||
info.udn,
|
|
||||||
info.friendly_name,
|
|
||||||
info.model_name
|
|
||||||
);
|
|
||||||
endpoint.seen_as_renderer = true;
|
|
||||||
}
|
|
||||||
updates.push(DeviceUpdate::RendererOnline(info));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(info) = self.provider.build_server_info(endpoint) {
|
|
||||||
if !endpoint.seen_as_server {
|
|
||||||
tracing::debug!(
|
|
||||||
"Server classified: udn={} friendly_name={} model={}",
|
|
||||||
info.udn,
|
|
||||||
info.friendly_name,
|
|
||||||
info.model_name
|
|
||||||
);
|
|
||||||
endpoint.seen_as_server = true;
|
|
||||||
}
|
|
||||||
updates.push(DeviceUpdate::ServerOnline(info));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_udn_from_usn(usn: &str) -> Option<String> {
|
|
||||||
let lower = usn.trim().to_ascii_lowercase();
|
|
||||||
if let Some(idx) = lower.find("uuid:") {
|
|
||||||
let sub = &lower[idx..];
|
|
||||||
if let Some(end) = sub.find("::") {
|
|
||||||
Some(sub[..end].to_string())
|
|
||||||
} else {
|
|
||||||
Some(sub.to_string())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,9 @@ use std::collections::HashMap;
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use crate::DeviceId;
|
||||||
use crate::registry::DeviceUpdate;
|
use crate::registry::DeviceUpdate;
|
||||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol, RendererId};
|
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
/// Information about a discovered Chromecast device from mDNS.
|
/// Information about a discovered Chromecast device from mDNS.
|
||||||
@@ -183,7 +184,7 @@ fn build_renderer_info(
|
|||||||
manufacturer: Option<&str>,
|
manufacturer: Option<&str>,
|
||||||
) -> RendererInfo {
|
) -> RendererInfo {
|
||||||
let udn = format!("uuid:{}", uuid);
|
let udn = format!("uuid:{}", uuid);
|
||||||
let id = RendererId(udn.clone());
|
let id = DeviceId(udn.clone());
|
||||||
|
|
||||||
// Build Chromecast capabilities
|
// Build Chromecast capabilities
|
||||||
let mut capabilities = RendererCapabilities::default();
|
let mut capabilities = RendererCapabilities::default();
|
||||||
@@ -193,42 +194,38 @@ fn build_renderer_info(
|
|||||||
// (not a real HTTP endpoint like UPnP, but useful for identification)
|
// (not a real HTTP endpoint like UPnP, but useful for identification)
|
||||||
let location = format!("chromecast://{}:{}", host, port);
|
let location = format!("chromecast://{}:{}", host, port);
|
||||||
|
|
||||||
RendererInfo {
|
RendererInfo::make(
|
||||||
id,
|
id,
|
||||||
udn,
|
udn,
|
||||||
friendly_name: friendly_name.to_string(),
|
friendly_name.to_string(),
|
||||||
model_name: model.unwrap_or("Chromecast").to_string(),
|
model.unwrap_or("Chromecast").to_string(),
|
||||||
manufacturer: manufacturer.unwrap_or("Google Inc.").to_string(),
|
manufacturer.unwrap_or("Google Inc.").to_string(),
|
||||||
protocol: RendererProtocol::ChromecastOnly,
|
RendererProtocol::ChromecastOnly,
|
||||||
capabilities,
|
capabilities,
|
||||||
location,
|
location,
|
||||||
server_header: "Chromecast".to_string(),
|
"Chromecast".to_string(),
|
||||||
online: true,
|
None,
|
||||||
last_seen: SystemTime::now(),
|
None,
|
||||||
max_age: 1800, // 30 minutes
|
None,
|
||||||
// All UPnP/OpenHome fields are None for Chromecast
|
None,
|
||||||
avtransport_service_type: None,
|
None,
|
||||||
avtransport_control_url: None,
|
None,
|
||||||
rendering_control_service_type: None,
|
None,
|
||||||
rendering_control_control_url: None,
|
None,
|
||||||
connection_manager_service_type: None,
|
None,
|
||||||
connection_manager_control_url: None,
|
None,
|
||||||
oh_playlist_service_type: None,
|
None,
|
||||||
oh_playlist_control_url: None,
|
None,
|
||||||
oh_playlist_event_sub_url: None,
|
None,
|
||||||
oh_info_service_type: None,
|
None,
|
||||||
oh_info_control_url: None,
|
None,
|
||||||
oh_info_event_sub_url: None,
|
None,
|
||||||
oh_time_service_type: None,
|
None,
|
||||||
oh_time_control_url: None,
|
None,
|
||||||
oh_time_event_sub_url: None,
|
None,
|
||||||
oh_volume_service_type: None,
|
None,
|
||||||
oh_volume_control_url: None,
|
None,
|
||||||
oh_radio_service_type: None,
|
)
|
||||||
oh_radio_control_url: None,
|
|
||||||
oh_product_service_type: None,
|
|
||||||
oh_product_control_url: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts the host (IP address) from a Chromecast location URL.
|
/// Extracts the host (IP address) from a Chromecast location URL.
|
||||||
40
pmocontrol/src/discovery/manager.rs
Normal file
40
pmocontrol/src/discovery/manager.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use std::{collections::HashMap, sync::{Arc, Mutex, OnceLock}, time::{Instant, SystemTime}};
|
||||||
|
|
||||||
|
use tracing_subscriber::registry;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct UDNSeen {
|
||||||
|
max_age: u64,
|
||||||
|
last_seen: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UDNRegistry {
|
||||||
|
seen: HashMap<String, UDNSeen>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UDNRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
UDNRegistry { seen: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the UDN has been seen for at least half of its lifetime
|
||||||
|
pub fn should_fetch(registry : Arc<Mutex<UDNRegistry>>,udn: &str, max_age: u64 ) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut r = registry
|
||||||
|
.lock()
|
||||||
|
.expect("UDNRegistry mutex lock failed");
|
||||||
|
if let Some(seen) = r.seen.get_mut(udn) {
|
||||||
|
if now.duration_since(seen.last_seen).as_secs() > max_age/2 {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
seen.last_seen = now;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.seen.insert(udn.to_string(), UDNSeen { max_age, last_seen: now });
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
6
pmocontrol/src/discovery/mod.rs
Normal file
6
pmocontrol/src/discovery/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod manager;
|
||||||
|
pub mod upnp_discovery;
|
||||||
|
pub mod upnp_provider;
|
||||||
|
pub mod chromecast_discovery;
|
||||||
|
|
||||||
|
|
||||||
508
pmocontrol/src/discovery/upnp_discovery.rs
Normal file
508
pmocontrol/src/discovery/upnp_discovery.rs
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use pmoupnp::ssdp::SsdpEvent;
|
||||||
|
|
||||||
|
use crate::discovery::manager::UDNRegistry;
|
||||||
|
use crate::media_server::UpnpMediaServer;
|
||||||
|
use crate::model::RendererInfo;
|
||||||
|
use crate::registry::DeviceUpdate;
|
||||||
|
|
||||||
|
/// Fournit les descriptions haut niveau à partir d’un endpoint découvert.
|
||||||
|
/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location`
|
||||||
|
/// et parser la description pour remplir RendererInfo / MediaServerInfo.
|
||||||
|
pub trait DeviceDescriptionProvider: Send + Sync {
|
||||||
|
/// Construit un RendererInfo pour cet endpoint, ou None s’il
|
||||||
|
/// ne correspond pas à un renderer audio intéressant.
|
||||||
|
fn build_renderer_info(
|
||||||
|
&self,
|
||||||
|
udn: &str,
|
||||||
|
location: &str,
|
||||||
|
server_header: &str,
|
||||||
|
) -> Option<RendererInfo>;
|
||||||
|
|
||||||
|
/// Construit un MediaServerInfo pour cet endpoint, ou None s’il
|
||||||
|
/// ne correspond pas à un media server (ou pas intéressant).
|
||||||
|
fn build_server_info(
|
||||||
|
&self,
|
||||||
|
udn: &str,
|
||||||
|
location: &str,
|
||||||
|
server_header: &str,
|
||||||
|
) -> Option<UpnpMediaServer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
||||||
|
pub struct DiscoveryManager<P>
|
||||||
|
where
|
||||||
|
P: DeviceDescriptionProvider,
|
||||||
|
{
|
||||||
|
provider: P,
|
||||||
|
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||||
|
device_registry: Arc<DeviceRegistry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UpnpDiscoveryManager {
|
||||||
|
device_registry: Arc<Mutex<DeviceRegistry>>,
|
||||||
|
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||||
|
http_client: Agent, // ← intégré
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpnpDiscoveryManager {
|
||||||
|
// Dans handle_ssdp_event (upnp_discovery.rs)
|
||||||
|
fn handle_ssdp_event(&mut self, event: SsdpEvent) {
|
||||||
|
let (alive, usn, location, max_age, server_header) = match event {
|
||||||
|
SsdpEvent::Alive {
|
||||||
|
usn,
|
||||||
|
location,
|
||||||
|
max_age,
|
||||||
|
server,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| SsdpEvent::SearchResponse {
|
||||||
|
usn,
|
||||||
|
location,
|
||||||
|
max_age,
|
||||||
|
server,
|
||||||
|
..
|
||||||
|
} => (true, usn, location, max_age, server),
|
||||||
|
SsdpEvent::ByeBye { usn, .. } => (false, usn, "".to_string(), 0, "".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||||
|
if alive {
|
||||||
|
// ✅ Check cache
|
||||||
|
if UDNRegistry::should_fetch(self.udn_cache, &udn, max_age as u64) {
|
||||||
|
// ✅ Fetch + parse
|
||||||
|
let info = self.provider.build_renderer_info(&location)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.device_registry.lock()
|
||||||
|
.expect("UDNRegistry mutex lock failed")
|
||||||
|
.device_says_byebye(&udn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch and parse the device description.xml at endpoint.location.
|
||||||
|
fn fetch_and_parse(
|
||||||
|
&self,
|
||||||
|
udn: &str,
|
||||||
|
location: &str,
|
||||||
|
server_header: &str,
|
||||||
|
) -> Result<ParsedDeviceDescription, DescriptionError> {
|
||||||
|
debug!("Fetching description for {} at {}", udn, location);
|
||||||
|
|
||||||
|
let config = Agent::config_builder()
|
||||||
|
.timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let agent: Agent = config.into();
|
||||||
|
|
||||||
|
let response = agent.get(location).call()?;
|
||||||
|
|
||||||
|
// response: http::Response<ureq::Body>
|
||||||
|
let (_parts, body) = response.into_parts();
|
||||||
|
|
||||||
|
// body.into_reader() -> impl Read + 'static
|
||||||
|
let body_reader = body.into_reader();
|
||||||
|
|
||||||
|
let mut reader = Reader::from_reader(BufReader::new(body_reader));
|
||||||
|
reader.config_mut().trim_text(true);
|
||||||
|
debug!("Parsing description XML for {} at {}", udn, location);
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut parsed = ParsedDeviceDescription::default();
|
||||||
|
|
||||||
|
let mut in_device = false;
|
||||||
|
let mut in_service = false;
|
||||||
|
let mut current_tag: Option<String> = None;
|
||||||
|
|
||||||
|
// New: track current serviceType + controlURL while inside <service>...</service>
|
||||||
|
let mut current_service_type: Option<String> = None;
|
||||||
|
let mut current_control_url: Option<String> = None;
|
||||||
|
let mut current_event_sub_url: Option<String> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf)? {
|
||||||
|
Event::Start(e) => {
|
||||||
|
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||||
|
match name.as_str() {
|
||||||
|
"device" => {
|
||||||
|
in_device = true;
|
||||||
|
current_tag = None;
|
||||||
|
}
|
||||||
|
"service" => {
|
||||||
|
if in_device {
|
||||||
|
in_service = true;
|
||||||
|
current_tag = None;
|
||||||
|
current_service_type = None;
|
||||||
|
current_control_url = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if in_device {
|
||||||
|
current_tag = Some(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::End(e) => {
|
||||||
|
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||||
|
match name.as_str() {
|
||||||
|
"device" => {
|
||||||
|
in_device = false;
|
||||||
|
}
|
||||||
|
"service" => {
|
||||||
|
if in_device && in_service {
|
||||||
|
// We just finished a <service> block: if this is AVTransport,
|
||||||
|
// store its endpoint in parsed.*
|
||||||
|
if let (Some(st), Some(ctrl)) =
|
||||||
|
(¤t_service_type, ¤t_control_url)
|
||||||
|
{
|
||||||
|
let lower = st.to_ascii_lowercase();
|
||||||
|
if lower.contains("urn:schemas-upnp-org:service:avtransport:") {
|
||||||
|
// Only set once; if multiple AVTransport services exist,
|
||||||
|
// we keep the first one.
|
||||||
|
if parsed.avtransport_service_type.is_none() {
|
||||||
|
parsed.avtransport_service_type = Some(st.clone());
|
||||||
|
parsed.avtransport_control_url = Some(ctrl.clone());
|
||||||
|
debug!(
|
||||||
|
"Found AVTransport service for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower
|
||||||
|
.contains("urn:schemas-upnp-org:service:renderingcontrol:")
|
||||||
|
{
|
||||||
|
if parsed.rendering_control_service_type.is_none() {
|
||||||
|
parsed.rendering_control_service_type =
|
||||||
|
Some(st.clone());
|
||||||
|
parsed.rendering_control_control_url =
|
||||||
|
Some(ctrl.clone());
|
||||||
|
debug!(
|
||||||
|
"Found RenderingControl service for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower
|
||||||
|
.contains("urn:schemas-upnp-org:service:connectionmanager:")
|
||||||
|
{
|
||||||
|
if parsed.connection_manager_service_type.is_none() {
|
||||||
|
parsed.connection_manager_service_type =
|
||||||
|
Some(st.clone());
|
||||||
|
parsed.connection_manager_control_url =
|
||||||
|
Some(ctrl.clone());
|
||||||
|
debug!(
|
||||||
|
"Found ConnectionManager service for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower
|
||||||
|
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
||||||
|
{
|
||||||
|
if parsed.content_directory_service_type.is_none() {
|
||||||
|
parsed.content_directory_service_type =
|
||||||
|
Some(st.clone());
|
||||||
|
parsed.content_directory_control_url =
|
||||||
|
Some(ctrl.clone());
|
||||||
|
debug!(
|
||||||
|
"Found ContentDirectory service for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower.contains("urn:av-openhome-org:service:playlist:") {
|
||||||
|
if parsed.oh_playlist_service_type.is_none() {
|
||||||
|
parsed.oh_playlist_service_type = Some(st.clone());
|
||||||
|
parsed.oh_playlist_control_url = Some(ctrl.clone());
|
||||||
|
if parsed.oh_playlist_event_sub_url.is_none() {
|
||||||
|
parsed.oh_playlist_event_sub_url =
|
||||||
|
current_event_sub_url.clone();
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
"Found OpenHome Playlist for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower.contains("urn:av-openhome-org:service:info:") {
|
||||||
|
if parsed.oh_info_service_type.is_none() {
|
||||||
|
parsed.oh_info_service_type = Some(st.clone());
|
||||||
|
parsed.oh_info_control_url = Some(ctrl.clone());
|
||||||
|
if parsed.oh_info_event_sub_url.is_none() {
|
||||||
|
parsed.oh_info_event_sub_url =
|
||||||
|
current_event_sub_url.clone();
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
"Found OpenHome Info for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower.contains("urn:av-openhome-org:service:time:") {
|
||||||
|
if parsed.oh_time_service_type.is_none() {
|
||||||
|
parsed.oh_time_service_type = Some(st.clone());
|
||||||
|
parsed.oh_time_control_url = Some(ctrl.clone());
|
||||||
|
if parsed.oh_time_event_sub_url.is_none() {
|
||||||
|
parsed.oh_time_event_sub_url =
|
||||||
|
current_event_sub_url.clone();
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
"Found OpenHome Time for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower.contains("urn:av-openhome-org:service:volume:") {
|
||||||
|
if parsed.oh_volume_service_type.is_none() {
|
||||||
|
parsed.oh_volume_service_type = Some(st.clone());
|
||||||
|
parsed.oh_volume_control_url = Some(ctrl.clone());
|
||||||
|
debug!(
|
||||||
|
"Found OpenHome Volume for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower.contains("urn:av-openhome-org:service:radio:") {
|
||||||
|
if parsed.oh_radio_service_type.is_none() {
|
||||||
|
parsed.oh_radio_service_type = Some(st.clone());
|
||||||
|
parsed.oh_radio_control_url = Some(ctrl.clone());
|
||||||
|
debug!(
|
||||||
|
"Found OpenHome Radio for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower.contains("urn:av-openhome-org:service:product:") {
|
||||||
|
if parsed.oh_product_service_type.is_none() {
|
||||||
|
parsed.oh_product_service_type = Some(st.clone());
|
||||||
|
parsed.oh_product_control_url = Some(ctrl.clone());
|
||||||
|
debug!(
|
||||||
|
"Found OpenHome Product for {}: type={} controlURL={}",
|
||||||
|
udn, st, ctrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
in_service = false;
|
||||||
|
current_service_type = None;
|
||||||
|
current_control_url = None;
|
||||||
|
current_event_sub_url = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
current_tag = None;
|
||||||
|
}
|
||||||
|
Event::Text(e) => {
|
||||||
|
if in_device {
|
||||||
|
if let Some(tag) = ¤t_tag {
|
||||||
|
// quick-xml ≥ 0.37 : unescape() → decode()
|
||||||
|
let text = e.decode().map_err(XmlError::Encoding)?.into_owned();
|
||||||
|
|
||||||
|
match tag.as_str() {
|
||||||
|
"UDN" => {
|
||||||
|
parsed.udn = Some(text);
|
||||||
|
}
|
||||||
|
"deviceType" => {
|
||||||
|
parsed.device_type = Some(text);
|
||||||
|
}
|
||||||
|
"friendlyName" => {
|
||||||
|
parsed.friendly_name = Some(text);
|
||||||
|
}
|
||||||
|
"manufacturer" => {
|
||||||
|
parsed.manufacturer = Some(text);
|
||||||
|
}
|
||||||
|
"modelName" => {
|
||||||
|
parsed.model_name = Some(text);
|
||||||
|
}
|
||||||
|
"serviceType" if in_service => {
|
||||||
|
parsed.service_types.push(text.clone());
|
||||||
|
current_service_type = Some(text);
|
||||||
|
}
|
||||||
|
"controlURL" if in_service => {
|
||||||
|
current_control_url = Some(text);
|
||||||
|
}
|
||||||
|
"eventSubURL" if in_service => {
|
||||||
|
current_event_sub_url = Some(text);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Eof => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.require_fields()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P> DiscoveryManager<P>
|
||||||
|
where
|
||||||
|
P: DeviceDescriptionProvider,
|
||||||
|
{
|
||||||
|
pub fn new(provider: P) -> Self {
|
||||||
|
Self { provider }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dans handle_ssdp_event (upnp_discovery.rs)
|
||||||
|
fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec<DeviceUpdate> {
|
||||||
|
let (alive, usn, location, max_age, server_header) = match event {
|
||||||
|
SsdpEvent::Alive {
|
||||||
|
usn,
|
||||||
|
location,
|
||||||
|
max_age,
|
||||||
|
server,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| SsdpEvent::SearchResponse {
|
||||||
|
usn,
|
||||||
|
location,
|
||||||
|
max_age,
|
||||||
|
server,
|
||||||
|
..
|
||||||
|
} => (true, usn, location, max_age, server),
|
||||||
|
SsdpEvent::ByeBye { usn, .. } => (false, usn, "".to_string(), 0, "".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||||
|
if alive {
|
||||||
|
// ✅ Check cache
|
||||||
|
if !UDNRegistry::should_fetch(&udn, max_age) {
|
||||||
|
return vec![]; // Skip, vu récemment
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Fetch + parse
|
||||||
|
let info = self.provider.build_renderer_info(&location)?;
|
||||||
|
vec![DeviceUpdate::RendererOnline(info)]
|
||||||
|
} else {
|
||||||
|
self.handle_byebye(udn, nt, &mut updates);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_alive(
|
||||||
|
&mut self,
|
||||||
|
udn: String,
|
||||||
|
nt: String,
|
||||||
|
location: String,
|
||||||
|
server_header: String,
|
||||||
|
max_age: u32,
|
||||||
|
updates: &mut Vec<DeviceUpdate>,
|
||||||
|
) {
|
||||||
|
self.update_endpoint(udn, nt, location, server_header, max_age, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_search_response(
|
||||||
|
&mut self,
|
||||||
|
udn: String,
|
||||||
|
st: String,
|
||||||
|
location: String,
|
||||||
|
server_header: String,
|
||||||
|
max_age: u32,
|
||||||
|
updates: &mut Vec<DeviceUpdate>,
|
||||||
|
) {
|
||||||
|
self.update_endpoint(udn, st, location, server_header, max_age, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec<DeviceUpdate>) {
|
||||||
|
if let Some(endpoint) = self.endpoints.get(&udn) {
|
||||||
|
if endpoint.seen_as_renderer {
|
||||||
|
updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone()));
|
||||||
|
}
|
||||||
|
if endpoint.seen_as_server {
|
||||||
|
updates.push(DeviceUpdate::ServerOfflineByUdn(udn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_endpoint(
|
||||||
|
&mut self,
|
||||||
|
udn: String,
|
||||||
|
device_type: String,
|
||||||
|
location: String,
|
||||||
|
server_header: String,
|
||||||
|
max_age: u32,
|
||||||
|
updates: &mut Vec<DeviceUpdate>,
|
||||||
|
) {
|
||||||
|
tracing::debug!(
|
||||||
|
"SSDP update: udn={} type={} location={} max_age={}",
|
||||||
|
udn,
|
||||||
|
device_type,
|
||||||
|
location,
|
||||||
|
max_age
|
||||||
|
);
|
||||||
|
|
||||||
|
let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({
|
||||||
|
let udn = udn.clone();
|
||||||
|
let location = location.clone();
|
||||||
|
let server_header = server_header.clone();
|
||||||
|
move || DiscoveredEndpoint::new(udn, location, server_header, max_age)
|
||||||
|
});
|
||||||
|
|
||||||
|
endpoint.touch(location, server_header, max_age);
|
||||||
|
endpoint.types_seen.insert(device_type);
|
||||||
|
|
||||||
|
// Toujours tenter de classifier et envoyer un événement Online pour maintenir
|
||||||
|
// l'état à jour dans le registre (important pour les serveurs qui ré-apparaissent)
|
||||||
|
if let Some(info) = self.provider.build_renderer_info(endpoint) {
|
||||||
|
if !endpoint.seen_as_renderer {
|
||||||
|
tracing::debug!(
|
||||||
|
"Renderer classified: udn={} friendly_name={} model={}",
|
||||||
|
info.udn(),
|
||||||
|
info.friendly_name(),
|
||||||
|
info.model_name()
|
||||||
|
);
|
||||||
|
endpoint.seen_as_renderer = true;
|
||||||
|
}
|
||||||
|
updates.push(DeviceUpdate::RendererOnline(info));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(info) = self.provider.build_server_info(endpoint) {
|
||||||
|
if !endpoint.seen_as_server {
|
||||||
|
tracing::debug!(
|
||||||
|
"Server classified: udn={} friendly_name={} model={}",
|
||||||
|
info.udn,
|
||||||
|
info.friendly_name,
|
||||||
|
info.model_name
|
||||||
|
);
|
||||||
|
endpoint.seen_as_server = true;
|
||||||
|
}
|
||||||
|
updates.push(DeviceUpdate::ServerOnline(info));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_udn_from_usn(usn: &str) -> Option<String> {
|
||||||
|
let lower = usn.trim().to_ascii_lowercase();
|
||||||
|
if let Some(idx) = lower.find("uuid:") {
|
||||||
|
let sub = &lower[idx..];
|
||||||
|
if let Some(end) = sub.find("::") {
|
||||||
|
Some(sub[..end].to_string())
|
||||||
|
} else {
|
||||||
|
Some(sub.to_string())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
|
|
||||||
use quick_xml::{events::Event, Error as XmlError, Reader};
|
use quick_xml::{Error as XmlError, Reader, events::Event};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::DeviceId;
|
||||||
use crate::arylic_tcp::detect_arylic_tcp;
|
use crate::arylic_tcp::detect_arylic_tcp;
|
||||||
use crate::avtransport_client::AvTransportClient;
|
use crate::avtransport_client::AvTransportClient;
|
||||||
use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint};
|
use crate::discovery::upnp_discovery::DeviceDescriptionProvider;
|
||||||
use crate::linkplay::detect_linkplay_http;
|
use crate::linkplay_renderer::detect_linkplay_http;
|
||||||
use crate::media_server::{MediaServerInfo, ServerId};
|
use crate::media_server::UpnpMediaServer;
|
||||||
use crate::model::{RendererCapabilities, RendererId, RendererInfo, RendererProtocol};
|
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
||||||
|
|
||||||
use ureq::Agent;
|
use ureq::Agent;
|
||||||
|
|
||||||
@@ -101,12 +102,11 @@ impl HttpXmlDescriptionProvider {
|
|||||||
/// Fetch and parse the device description.xml at endpoint.location.
|
/// Fetch and parse the device description.xml at endpoint.location.
|
||||||
fn fetch_and_parse(
|
fn fetch_and_parse(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &DiscoveredEndpoint,
|
udn: &str,
|
||||||
|
location: &str,
|
||||||
|
server_header: &str,
|
||||||
) -> Result<ParsedDeviceDescription, DescriptionError> {
|
) -> Result<ParsedDeviceDescription, DescriptionError> {
|
||||||
debug!(
|
debug!("Fetching description for {} at {}", udn, location);
|
||||||
"Fetching description for {} at {}",
|
|
||||||
endpoint.udn, endpoint.location
|
|
||||||
);
|
|
||||||
|
|
||||||
let config = Agent::config_builder()
|
let config = Agent::config_builder()
|
||||||
.timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs)))
|
.timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs)))
|
||||||
@@ -114,7 +114,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
|
|
||||||
let agent: Agent = config.into();
|
let agent: Agent = config.into();
|
||||||
|
|
||||||
let response = agent.get(&endpoint.location).call()?;
|
let response = agent.get(location).call()?;
|
||||||
|
|
||||||
// response: http::Response<ureq::Body>
|
// response: http::Response<ureq::Body>
|
||||||
let (_parts, body) = response.into_parts();
|
let (_parts, body) = response.into_parts();
|
||||||
@@ -124,10 +124,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
|
|
||||||
let mut reader = Reader::from_reader(BufReader::new(body_reader));
|
let mut reader = Reader::from_reader(BufReader::new(body_reader));
|
||||||
reader.config_mut().trim_text(true);
|
reader.config_mut().trim_text(true);
|
||||||
debug!(
|
debug!("Parsing description XML for {} at {}", udn, location);
|
||||||
"Parsing description XML for {} at {}",
|
|
||||||
endpoint.udn, endpoint.location
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut parsed = ParsedDeviceDescription::default();
|
let mut parsed = ParsedDeviceDescription::default();
|
||||||
@@ -187,7 +184,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
parsed.avtransport_control_url = Some(ctrl.clone());
|
parsed.avtransport_control_url = Some(ctrl.clone());
|
||||||
debug!(
|
debug!(
|
||||||
"Found AVTransport service for {}: type={} controlURL={}",
|
"Found AVTransport service for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +199,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
Some(ctrl.clone());
|
Some(ctrl.clone());
|
||||||
debug!(
|
debug!(
|
||||||
"Found RenderingControl service for {}: type={} controlURL={}",
|
"Found RenderingControl service for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +214,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
Some(ctrl.clone());
|
Some(ctrl.clone());
|
||||||
debug!(
|
debug!(
|
||||||
"Found ConnectionManager service for {}: type={} controlURL={}",
|
"Found ConnectionManager service for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +229,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
Some(ctrl.clone());
|
Some(ctrl.clone());
|
||||||
debug!(
|
debug!(
|
||||||
"Found ContentDirectory service for {}: type={} controlURL={}",
|
"Found ContentDirectory service for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,7 +244,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
}
|
}
|
||||||
debug!(
|
debug!(
|
||||||
"Found OpenHome Playlist for {}: type={} controlURL={}",
|
"Found OpenHome Playlist for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +259,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
}
|
}
|
||||||
debug!(
|
debug!(
|
||||||
"Found OpenHome Info for {}: type={} controlURL={}",
|
"Found OpenHome Info for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,7 +274,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
}
|
}
|
||||||
debug!(
|
debug!(
|
||||||
"Found OpenHome Time for {}: type={} controlURL={}",
|
"Found OpenHome Time for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,7 +285,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
parsed.oh_volume_control_url = Some(ctrl.clone());
|
parsed.oh_volume_control_url = Some(ctrl.clone());
|
||||||
debug!(
|
debug!(
|
||||||
"Found OpenHome Volume for {}: type={} controlURL={}",
|
"Found OpenHome Volume for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +296,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
parsed.oh_radio_control_url = Some(ctrl.clone());
|
parsed.oh_radio_control_url = Some(ctrl.clone());
|
||||||
debug!(
|
debug!(
|
||||||
"Found OpenHome Radio for {}: type={} controlURL={}",
|
"Found OpenHome Radio for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,7 +307,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
parsed.oh_product_control_url = Some(ctrl.clone());
|
parsed.oh_product_control_url = Some(ctrl.clone());
|
||||||
debug!(
|
debug!(
|
||||||
"Found OpenHome Product for {}: type={} controlURL={}",
|
"Found OpenHome Product for {}: type={} controlURL={}",
|
||||||
endpoint.udn, st, ctrl
|
udn, st, ctrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,7 +372,9 @@ impl HttpXmlDescriptionProvider {
|
|||||||
|
|
||||||
fn build_renderer(
|
fn build_renderer(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &DiscoveredEndpoint,
|
udn: &str,
|
||||||
|
location: &str,
|
||||||
|
server_header: &str,
|
||||||
parsed: &ParsedDeviceDescription,
|
parsed: &ParsedDeviceDescription,
|
||||||
) -> Option<RendererInfo> {
|
) -> Option<RendererInfo> {
|
||||||
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
||||||
@@ -385,110 +384,101 @@ impl HttpXmlDescriptionProvider {
|
|||||||
{
|
{
|
||||||
debug!(
|
debug!(
|
||||||
"build_renderer: ignoring deviceType for {}: {}",
|
"build_renderer: ignoring deviceType for {}: {}",
|
||||||
endpoint.udn, device_type
|
udn, device_type
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw_udn = parsed
|
let raw_udn = parsed.udn.as_deref().unwrap_or_else(|| udn);
|
||||||
.udn
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or_else(|| endpoint.udn.as_str());
|
|
||||||
let udn = raw_udn.to_ascii_lowercase();
|
let udn = raw_udn.to_ascii_lowercase();
|
||||||
let mut caps = detect_renderer_capabilities(&parsed.service_types);
|
let mut caps = detect_renderer_capabilities(&parsed.service_types);
|
||||||
if detect_linkplay_http(
|
if detect_linkplay_http(location, Duration::from_secs(self.timeout_secs.max(1))) {
|
||||||
&endpoint.location,
|
|
||||||
Duration::from_secs(self.timeout_secs.max(1)),
|
|
||||||
) {
|
|
||||||
caps.has_linkplay_http = true;
|
caps.has_linkplay_http = true;
|
||||||
}
|
}
|
||||||
if detect_arylic_tcp(
|
if detect_arylic_tcp(location, Duration::from_secs(self.timeout_secs.max(1))) {
|
||||||
&endpoint.location,
|
|
||||||
Duration::from_secs(self.timeout_secs.max(1)),
|
|
||||||
) {
|
|
||||||
caps.has_arylic_tcp = true;
|
caps.has_arylic_tcp = true;
|
||||||
}
|
}
|
||||||
let protocol = detect_renderer_protocol(&caps);
|
let protocol = detect_renderer_protocol(&caps);
|
||||||
let now = SystemTime::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
Some(RendererInfo {
|
Some(RendererInfo::make(
|
||||||
id: RendererId(udn.clone()),
|
DeviceId(udn.clone()),
|
||||||
udn,
|
udn,
|
||||||
friendly_name: parsed.friendly_name.clone().unwrap_or_default(),
|
parsed.friendly_name.clone().unwrap_or_default(),
|
||||||
model_name: parsed.model_name.clone().unwrap_or_default(),
|
parsed.model_name.clone().unwrap_or_default(),
|
||||||
manufacturer: parsed.manufacturer.clone().unwrap_or_default(),
|
parsed.manufacturer.clone().unwrap_or_default(),
|
||||||
protocol,
|
protocol,
|
||||||
capabilities: caps,
|
caps,
|
||||||
location: endpoint.location.clone(),
|
location.to_string(),
|
||||||
server_header: endpoint.server_header.clone(),
|
endpoint.server_header.clone(),
|
||||||
online: true,
|
// online: true,
|
||||||
last_seen: now,
|
// last_seen: now,
|
||||||
max_age: endpoint.max_age,
|
// max_age: endpoint.max_age,
|
||||||
avtransport_service_type: parsed.avtransport_service_type.clone(),
|
parsed.avtransport_service_type.clone(),
|
||||||
avtransport_control_url: parsed
|
parsed
|
||||||
.avtransport_control_url
|
.avtransport_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
.map(|ctrl| resolve_control_url(location, ctrl)),
|
||||||
rendering_control_service_type: parsed.rendering_control_service_type.clone(),
|
parsed.rendering_control_service_type.clone(),
|
||||||
rendering_control_control_url: parsed
|
parsed
|
||||||
.rendering_control_control_url
|
.rendering_control_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
.map(|ctrl| resolve_control_url(location, ctrl)),
|
||||||
connection_manager_service_type: parsed.connection_manager_service_type.clone(),
|
parsed.connection_manager_service_type.clone(),
|
||||||
connection_manager_control_url: parsed
|
parsed
|
||||||
.connection_manager_control_url
|
.connection_manager_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
.map(|ctrl| resolve_control_url(location, ctrl)),
|
||||||
oh_playlist_service_type: parsed.oh_playlist_service_type.clone(),
|
parsed.oh_playlist_service_type.clone(),
|
||||||
oh_playlist_control_url: parsed
|
parsed
|
||||||
.oh_playlist_control_url
|
.oh_playlist_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
.map(|ctrl| resolve_control_url(location, ctrl)),
|
||||||
oh_playlist_event_sub_url: parsed
|
parsed
|
||||||
.oh_playlist_event_sub_url
|
.oh_playlist_event_sub_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|url| resolve_control_url(&endpoint.location, url)),
|
.map(|url| resolve_control_url(location, url)),
|
||||||
oh_info_service_type: parsed.oh_info_service_type.clone(),
|
parsed.oh_info_service_type.clone(),
|
||||||
oh_info_control_url: parsed
|
parsed
|
||||||
.oh_info_control_url
|
.oh_info_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
.map(|ctrl| resolve_control_url(location, ctrl)),
|
||||||
oh_info_event_sub_url: parsed
|
parsed
|
||||||
.oh_info_event_sub_url
|
.oh_info_event_sub_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|url| resolve_control_url(&endpoint.location, url)),
|
.map(|url| resolve_control_url(location, url)),
|
||||||
oh_time_service_type: parsed.oh_time_service_type.clone(),
|
parsed.oh_time_service_type.clone(),
|
||||||
oh_time_control_url: parsed
|
parsed
|
||||||
.oh_time_control_url
|
.oh_time_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
.map(|ctrl| resolve_control_url(location, ctrl)),
|
||||||
oh_time_event_sub_url: parsed
|
parsed
|
||||||
.oh_time_event_sub_url
|
.oh_time_event_sub_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|url| resolve_control_url(&endpoint.location, url)),
|
.map(|url| resolve_control_url(location, url)),
|
||||||
oh_volume_service_type: parsed.oh_volume_service_type.clone(),
|
parsed.oh_volume_service_type.clone(),
|
||||||
oh_volume_control_url: parsed
|
parsed
|
||||||
.oh_volume_control_url
|
.oh_volume_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
.map(|ctrl| resolve_control_url(location, ctrl)),
|
||||||
oh_radio_service_type: parsed.oh_radio_service_type.clone(),
|
parsed.oh_radio_service_type.clone(),
|
||||||
oh_radio_control_url: parsed
|
parsed
|
||||||
.oh_radio_control_url
|
.oh_radio_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
.map(|ctrl| resolve_control_url(location, ctrl)),
|
||||||
oh_product_service_type: parsed.oh_product_service_type.clone(),
|
parsed.oh_product_service_type.clone(),
|
||||||
oh_product_control_url: parsed
|
parsed
|
||||||
.oh_product_control_url
|
.oh_product_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
.map(|ctrl| resolve_control_url(location, ctrl)),
|
||||||
})
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_server(
|
fn build_server(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &DiscoveredEndpoint,
|
endpoint: &DiscoveredEndpoint,
|
||||||
parsed: &ParsedDeviceDescription,
|
parsed: &ParsedDeviceDescription,
|
||||||
) -> Option<MediaServerInfo> {
|
) -> Option<UpnpMediaServer> {
|
||||||
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
||||||
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
|
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
|
||||||
return None;
|
return None;
|
||||||
@@ -503,15 +493,15 @@ impl HttpXmlDescriptionProvider {
|
|||||||
st.to_ascii_lowercase()
|
st.to_ascii_lowercase()
|
||||||
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
||||||
});
|
});
|
||||||
let now = SystemTime::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
let content_directory_control_url = parsed
|
let content_directory_control_url = parsed
|
||||||
.content_directory_control_url
|
.content_directory_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl));
|
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl));
|
||||||
|
|
||||||
Some(MediaServerInfo {
|
Some(UpnpMediaServer {
|
||||||
id: ServerId(udn.clone()),
|
id: DeviceId(udn.clone()),
|
||||||
udn,
|
udn,
|
||||||
friendly_name: parsed.friendly_name.clone().unwrap_or_default(),
|
friendly_name: parsed.friendly_name.clone().unwrap_or_default(),
|
||||||
model_name: parsed.model_name.clone().unwrap_or_default(),
|
model_name: parsed.model_name.clone().unwrap_or_default(),
|
||||||
@@ -604,7 +594,7 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol {
|
|||||||
|| caps.has_oh_radio;
|
|| caps.has_oh_radio;
|
||||||
|
|
||||||
match (has_upnp_av, has_openhome) {
|
match (has_upnp_av, has_openhome) {
|
||||||
(true, true) => RendererProtocol::Hybrid,
|
(true, true) => RendererProtocol::OpenHomeHybrid,
|
||||||
(true, false) => RendererProtocol::UpnpAvOnly,
|
(true, false) => RendererProtocol::UpnpAvOnly,
|
||||||
(false, true) => RendererProtocol::OpenHomeOnly,
|
(false, true) => RendererProtocol::OpenHomeOnly,
|
||||||
(false, false) => RendererProtocol::UpnpAvOnly,
|
(false, false) => RendererProtocol::UpnpAvOnly,
|
||||||
@@ -679,7 +669,7 @@ impl DeviceDescriptionProvider for HttpXmlDescriptionProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option<MediaServerInfo> {
|
fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option<UpnpMediaServer> {
|
||||||
match self.fetch_and_parse(endpoint) {
|
match self.fetch_and_parse(endpoint) {
|
||||||
Ok(parsed) => {
|
Ok(parsed) => {
|
||||||
let device_type = parsed.device_type.as_deref().unwrap_or("unknown");
|
let device_type = parsed.device_type.as_deref().unwrap_or("unknown");
|
||||||
65
pmocontrol/src/errors.rs
Normal file
65
pmocontrol/src/errors.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ControlPointError {
|
||||||
|
// Utiliser par les DeviceItems dans leur conversion vers un media renderer
|
||||||
|
#[error("{0} is not a MediaRenderer")]
|
||||||
|
IsNotAMediaRender(String),
|
||||||
|
#[error("{0} is not a MediaServer")]
|
||||||
|
IsNotAMediaServer(String),
|
||||||
|
#[error("Cannot build MusicRendererBackend for {0}")]
|
||||||
|
MusicRendererBackendBuild(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
ParsingError(String),
|
||||||
|
#[error("OpenHome Error: {0}")]
|
||||||
|
OpenHomeError(String),
|
||||||
|
#[error("OpenHome Product source list does not expose a Playlist entry")]
|
||||||
|
OpenHomeNoPlaylistEntry(),
|
||||||
|
#[error("MusicRendererBackend {0} exposes no OpenHome services")]
|
||||||
|
OpenHomeNotAValidDevice(String),
|
||||||
|
#[error("UpnpError Error: {0}")]
|
||||||
|
UpnpError(String),
|
||||||
|
#[error("MusicRenderer operation '{0}' is not supported by backend '{1}'")]
|
||||||
|
UpnpOperationNotSupported(String, String),
|
||||||
|
#[error("Missing {0} element in SOAP body")]
|
||||||
|
UpnpMissingReturnValue(String),
|
||||||
|
#[error("Invalid {0} value: {1}")]
|
||||||
|
UpnpBadReturnValue(String,String),
|
||||||
|
#[error("Soap Error: Upnp action call {0}")]
|
||||||
|
SoapAction(String),
|
||||||
|
#[error("{0} returned UPnP error {1}: {2} (HTTP status {3})")]
|
||||||
|
SoapUpnpParseError(String,u32, String, u32),
|
||||||
|
#[error("{0} failed with HTTP status {1} and body: {2}")]
|
||||||
|
SoapActionWrongBody(String, u32, String),
|
||||||
|
#[error("Soap Error: No envelop for action {0}")]
|
||||||
|
SoapNoEnvelop(String),
|
||||||
|
#[error("ArilycTcp Error: {0}")]
|
||||||
|
ArilycTcpError(String),
|
||||||
|
#[error("LinkPlay Error: {0}")]
|
||||||
|
LinkPlayError(String),
|
||||||
|
#[error("Chromecast Error: {0}")]
|
||||||
|
ChromecastError(String),
|
||||||
|
#[error("MediaServer Error: {0}")]
|
||||||
|
MediaServerError(String),
|
||||||
|
#[error("Queue Error: {0}")]
|
||||||
|
QueueError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlPointError {
|
||||||
|
pub fn upnp_operation_not_supported(operation: &str, service: &str) -> Self {
|
||||||
|
ControlPointError::UpnpOperationNotSupported(operation.to_string(),service.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upnp_missing_return_value(value: &str) -> Self {
|
||||||
|
ControlPointError::UpnpMissingReturnValue(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upnp_bad_return_value(name : &str, value : &str ) -> Self {
|
||||||
|
ControlPointError::UpnpBadReturnValue(name.to_string(),value.to_string())
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn arilyc_tcp_error(message : &str ) -> Self {
|
||||||
|
ControlPointError::ArilycTcpError(message.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
14
pmocontrol/src/identity.rs
Normal file
14
pmocontrol/src/identity.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::DeviceId;
|
||||||
|
|
||||||
|
pub trait DeviceIdentity {
|
||||||
|
fn id(&self) -> DeviceId ;
|
||||||
|
fn udn(&self) -> &str;
|
||||||
|
fn friendly_name(&self) -> &str ;
|
||||||
|
fn model_name(&self) -> &str ;
|
||||||
|
fn manufacturer(&self) -> &str ;
|
||||||
|
fn location(&self) -> &str ;
|
||||||
|
fn server_header(&self) -> &str;
|
||||||
|
|
||||||
|
fn is_a_media_server(&self) -> bool { false }
|
||||||
|
fn is_a_music_renderer(&self) -> bool { false }
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
mod events;
|
mod events;
|
||||||
mod media_server_events;
|
mod media_server_events;
|
||||||
|
|
||||||
|
pub mod queue;
|
||||||
|
pub mod discovery;
|
||||||
pub mod arylic_tcp;
|
pub mod arylic_tcp;
|
||||||
pub mod avtransport_client;
|
pub mod avtransport_client;
|
||||||
pub mod capabilities;
|
pub mod capabilities;
|
||||||
pub mod chromecast_discovery;
|
|
||||||
pub mod chromecast_renderer;
|
pub mod chromecast_renderer;
|
||||||
pub mod connection_manager_client;
|
pub mod connection_manager_client;
|
||||||
pub mod control_point;
|
pub mod control_point;
|
||||||
pub mod discovery;
|
pub mod errors;
|
||||||
pub mod linkplay;
|
pub mod linkplay_renderer;
|
||||||
pub mod media_server;
|
pub mod media_server;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod music_renderer;
|
pub mod music_renderer;
|
||||||
@@ -18,12 +19,13 @@ pub mod openhome_client;
|
|||||||
pub mod openhome_playlist;
|
pub mod openhome_playlist;
|
||||||
pub mod openhome_renderer;
|
pub mod openhome_renderer;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod queue_backend;
|
|
||||||
pub mod queue_interne;
|
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod rendering_control_client;
|
pub mod rendering_control_client;
|
||||||
pub mod soap_client;
|
pub mod soap_client;
|
||||||
pub mod upnp_renderer;
|
pub mod upnp_renderer;
|
||||||
|
pub mod online;
|
||||||
|
pub mod identity;
|
||||||
|
|
||||||
|
|
||||||
// pmoserver extension (optional)
|
// pmoserver extension (optional)
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
@@ -33,6 +35,8 @@ pub mod pmoserver_ext;
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub use pmoserver_ext::ControlPointExt;
|
pub use pmoserver_ext::ControlPointExt;
|
||||||
|
|
||||||
@@ -45,24 +49,30 @@ pub use capabilities::{
|
|||||||
pub use chromecast_renderer::ChromecastRenderer;
|
pub use chromecast_renderer::ChromecastRenderer;
|
||||||
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||||
pub use control_point::{ControlPoint, PlaylistBinding};
|
pub use control_point::{ControlPoint, PlaylistBinding};
|
||||||
pub use linkplay::LinkPlayRenderer;
|
pub use linkplay_renderer::LinkPlayRenderer;
|
||||||
pub use media_server::{
|
pub use media_server::{
|
||||||
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer,
|
||||||
UpnpMediaServer,
|
|
||||||
};
|
};
|
||||||
pub use music_renderer::MusicRenderer;
|
pub use music_renderer::MusicRendererBackend;
|
||||||
pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
pub use openhome_renderer::OpenHomeRenderer;
|
pub use openhome_renderer::OpenHomeRenderer;
|
||||||
pub use queue_backend::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
||||||
pub use rendering_control_client::RenderingControlClient;
|
pub use rendering_control_client::RenderingControlClient;
|
||||||
pub use upnp_renderer::UpnpRenderer;
|
pub use upnp_renderer::UpnpRenderer;
|
||||||
|
|
||||||
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
|
||||||
pub use model::{
|
pub use model::{
|
||||||
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo,
|
MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol,
|
||||||
RendererProtocol,
|
|
||||||
};
|
};
|
||||||
pub use provider::HttpXmlDescriptionProvider;
|
pub use provider::HttpXmlDescriptionProvider;
|
||||||
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
pub use registry::{DeviceRegistry, DeviceUpdate};
|
||||||
|
|
||||||
pub use soap_client::invoke_upnp_action;
|
pub use soap_client::invoke_upnp_action;
|
||||||
|
pub use online::DeviceOnline;
|
||||||
|
|
||||||
|
pub use identity::DeviceIdentity;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct DeviceId(pub String);
|
||||||
|
|
||||||
|
const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ use anyhow::{Context, Result, anyhow};
|
|||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
use ureq::Agent;
|
use ureq::Agent;
|
||||||
|
|
||||||
|
use crate::DeviceIdentity;
|
||||||
use crate::capabilities::{
|
use crate::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||||
VolumeControl,
|
VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::model::{RendererId, RendererInfo};
|
use crate::errors::ControlPointError;
|
||||||
|
use crate::model::{RendererInfo};
|
||||||
|
|
||||||
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||||
const STATUS_COMMAND: &str = "getPlayerStatus";
|
const STATUS_COMMAND: &str = "getPlayerStatus";
|
||||||
@@ -19,7 +21,6 @@ const STATUS_COMMAND: &str = "getPlayerStatus";
|
|||||||
/// Renderer backend for devices exposing the LinkPlay HTTP API.
|
/// Renderer backend for devices exposing the LinkPlay HTTP API.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LinkPlayRenderer {
|
pub struct LinkPlayRenderer {
|
||||||
pub info: RendererInfo,
|
|
||||||
host: String,
|
host: String,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
}
|
}
|
||||||
@@ -27,8 +28,6 @@ pub struct LinkPlayRenderer {
|
|||||||
impl fmt::Debug for LinkPlayRenderer {
|
impl fmt::Debug for LinkPlayRenderer {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("LinkPlayRenderer")
|
f.debug_struct("LinkPlayRenderer")
|
||||||
.field("id", &self.info.id)
|
|
||||||
.field("friendly_name", &self.info.friendly_name)
|
|
||||||
.field("host", &self.host)
|
.field("host", &self.host)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -36,12 +35,11 @@ impl fmt::Debug for LinkPlayRenderer {
|
|||||||
|
|
||||||
impl LinkPlayRenderer {
|
impl LinkPlayRenderer {
|
||||||
/// Build a LinkPlay backend from a registry snapshot.
|
/// Build a LinkPlay backend from a registry snapshot.
|
||||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
pub fn from_renderer_info(info: RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
let host = extract_linkplay_host(&info.location)
|
let host = extract_linkplay_host(&info.location())
|
||||||
.ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?;
|
.ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
info,
|
|
||||||
host,
|
host,
|
||||||
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
||||||
})
|
})
|
||||||
@@ -51,15 +49,7 @@ impl LinkPlayRenderer {
|
|||||||
build_agent(self.timeout)
|
build_agent(self.timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> &RendererId {
|
fn send_player_command(&self, command: &str) -> Result<(), ControlPointError> {
|
||||||
&self.info.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn friendly_name(&self) -> &str {
|
|
||||||
&self.info.friendly_name
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_player_command(&self, command: &str) -> Result<()> {
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
|
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
|
||||||
self.host, command
|
self.host, command
|
||||||
@@ -67,67 +57,67 @@ impl LinkPlayRenderer {
|
|||||||
self.agent()
|
self.agent()
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.call()
|
.call()
|
||||||
.with_context(|| format!("LinkPlay command {} failed for {}", command, self.host))?;
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_status(&self) -> Result<LinkPlayStatus> {
|
fn fetch_status(&self) -> Result<LinkPlayStatus, ControlPointError> {
|
||||||
fetch_status_for_host(&self.host, self.timeout)
|
fetch_status_for_host(&self.host, self.timeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportControl for LinkPlayRenderer {
|
impl TransportControl for LinkPlayRenderer {
|
||||||
fn play_uri(&self, uri: &str, _meta: &str) -> Result<()> {
|
fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||||
let encoded = percent_encode(uri);
|
let encoded = percent_encode(uri);
|
||||||
self.send_player_command(&format!("play:{}", encoded))
|
self.send_player_command(&format!("play:{}", encoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play(&self) -> Result<()> {
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
self.send_player_command("resume")
|
self.send_player_command("resume")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pause(&self) -> Result<()> {
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
self.send_player_command("pause")
|
self.send_player_command("pause")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&self) -> Result<()> {
|
fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
self.send_player_command("stop")
|
self.send_player_command("stop")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
let secs = parse_hhmmss_to_secs(hhmmss)
|
let secs = parse_hhmmss_to_secs(hhmmss)
|
||||||
.ok_or_else(|| anyhow!("Invalid seek position format: {}", hhmmss))?;
|
.ok_or_else(|| ControlPointError::LinkPlayError(format!("Invalid seek position format: {}", hhmmss)))?;
|
||||||
self.send_player_command(&format!("seek:{}", secs))
|
self.send_player_command(&format!("seek:{}", secs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VolumeControl for LinkPlayRenderer {
|
impl VolumeControl for LinkPlayRenderer {
|
||||||
fn volume(&self) -> Result<u16> {
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
Ok(self.fetch_status()?.volume)
|
Ok(self.fetch_status()?.volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&self, v: u16) -> Result<()> {
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||||
let value = v.min(100);
|
let value = v.min(100);
|
||||||
self.send_player_command(&format!("vol:{}", value))
|
self.send_player_command(&format!("vol:{}", value))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool> {
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
Ok(self.fetch_status()?.mute)
|
Ok(self.fetch_status()?.mute)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_mute(&self, m: bool) -> Result<()> {
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||||
self.send_player_command(if m { "mute:1" } else { "mute:0" })
|
self.send_player_command(if m { "mute:1" } else { "mute:0" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackStatus for LinkPlayRenderer {
|
impl PlaybackStatus for LinkPlayRenderer {
|
||||||
fn playback_state(&self) -> Result<PlaybackState> {
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
Ok(self.fetch_status()?.playback_state())
|
Ok(self.fetch_status()?.playback_state())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackPosition for LinkPlayRenderer {
|
impl PlaybackPosition for LinkPlayRenderer {
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
Ok(self.fetch_status()?.position_info())
|
Ok(self.fetch_status()?.position_info())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,17 +164,17 @@ pub fn extract_linkplay_host(location: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_status_for_host(host: &str, timeout: Duration) -> Result<LinkPlayStatus> {
|
fn fetch_status_for_host(host: &str, timeout: Duration) -> Result<LinkPlayStatus, ControlPointError> {
|
||||||
let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
|
let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
|
||||||
let mut response = build_agent(timeout)
|
let mut response = build_agent(timeout)
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.call()
|
.call()
|
||||||
.with_context(|| format!("HTTP request failed for LinkPlay status on {}", host))?;
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?;
|
||||||
|
|
||||||
let body = response
|
let body = response
|
||||||
.body_mut()
|
.body_mut()
|
||||||
.read_to_string()
|
.read_to_string()
|
||||||
.context("Failed to read LinkPlay status body")?;
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?;
|
||||||
|
|
||||||
parse_linkplay_status(&body)
|
parse_linkplay_status(&body)
|
||||||
}
|
}
|
||||||
@@ -233,12 +223,12 @@ impl LinkPlayStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus> {
|
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError> {
|
||||||
let mut map = parse_flat_json(body)?;
|
let mut map = parse_flat_json(body)?;
|
||||||
|
|
||||||
let state_raw = map
|
let state_raw = map
|
||||||
.remove("status")
|
.remove("status")
|
||||||
.ok_or_else(|| anyhow!("LinkPlay status missing `status` field"))?;
|
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `status` field")))?;
|
||||||
|
|
||||||
let curpos_ms = parse_u64_field(&map, "curpos")?;
|
let curpos_ms = parse_u64_field(&map, "curpos")?;
|
||||||
let totlen_ms = parse_u64_field(&map, "totlen")?;
|
let totlen_ms = parse_u64_field(&map, "totlen")?;
|
||||||
@@ -247,9 +237,9 @@ fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus> {
|
|||||||
Some("1") => true,
|
Some("1") => true,
|
||||||
Some("0") => false,
|
Some("0") => false,
|
||||||
Some(other) => {
|
Some(other) => {
|
||||||
return Err(anyhow!("Invalid LinkPlay mute value: {}", other));
|
return Err(ControlPointError::ArilycTcpError(format!("Invalid LinkPlay mute value: {}", other)));
|
||||||
}
|
}
|
||||||
None => return Err(anyhow!("LinkPlay status missing `mute` field")),
|
None => return Err(ControlPointError::arilyc_tcp_error("LinkPlay status missing `mute` field")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let track_index = map
|
let track_index = map
|
||||||
@@ -267,29 +257,29 @@ fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64> {
|
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64, ControlPointError> {
|
||||||
let raw = map
|
let raw = map
|
||||||
.get(key)
|
.get(key)
|
||||||
.ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?;
|
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?;
|
||||||
raw.parse::<u64>()
|
raw.parse::<u64>()
|
||||||
.with_context(|| format!("Invalid `{}` value: {}", key, raw))
|
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_u16_field(map: &HashMap<String, String>, key: &str) -> Result<u16> {
|
fn parse_u16_field(map: &HashMap<String, String>, key: &str) -> Result<u16, ControlPointError> {
|
||||||
let raw = map
|
let raw = map
|
||||||
.get(key)
|
.get(key)
|
||||||
.ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?;
|
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?;
|
||||||
let value = raw
|
let value = raw
|
||||||
.parse::<u16>()
|
.parse::<u16>()
|
||||||
.with_context(|| format!("Invalid `{}` value: {}", key, raw))?;
|
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw)))?;
|
||||||
Ok(value.min(100))
|
Ok(value.min(100))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_flat_json(input: &str) -> Result<HashMap<String, String>> {
|
pub(crate) fn parse_flat_json(input: &str) -> Result<HashMap<String, String>, ControlPointError> {
|
||||||
let mut chars = input.chars().peekable();
|
let mut chars = input.chars().peekable();
|
||||||
skip_ws(&mut chars);
|
skip_ws(&mut chars);
|
||||||
if chars.next() != Some('{') {
|
if chars.next() != Some('{') {
|
||||||
return Err(anyhow!("LinkPlay status is not a JSON object"));
|
return Err(ControlPointError::ArilycTcpError(format!("LinkPlay status is not a JSON object")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
@@ -301,7 +291,7 @@ pub(crate) fn parse_flat_json(input: &str) -> Result<HashMap<String, String>> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Some(_) => {}
|
Some(_) => {}
|
||||||
None => return Err(anyhow!("Unexpected end of JSON object")),
|
None => return Err(ControlPointError::ArilycTcpError(format!("Unexpected end of JSON object"))),
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = parse_json_string(&mut chars)?;
|
let key = parse_json_string(&mut chars)?;
|
||||||
@@ -322,19 +312,19 @@ pub(crate) fn parse_flat_json(input: &str) -> Result<HashMap<String, String>> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Some(other) => {
|
Some(other) => {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"Unexpected character '{}' while parsing JSON",
|
"Unexpected character '{}' while parsing JSON",
|
||||||
other
|
other
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
None => return Err(anyhow!("Unexpected end of JSON while parsing fields")),
|
None => return Err(ControlPointError::ArilycTcpError(format!("Unexpected end of JSON while parsing fields"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_json_value(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
|
fn parse_json_value(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String, ControlPointError> {
|
||||||
match chars.peek() {
|
match chars.peek() {
|
||||||
Some('"') => parse_json_string(chars),
|
Some('"') => parse_json_string(chars),
|
||||||
Some(ch) if ch.is_ascii_digit() || *ch == '-' => parse_json_number(chars),
|
Some(ch) if ch.is_ascii_digit() || *ch == '-' => parse_json_number(chars),
|
||||||
@@ -346,13 +336,13 @@ fn parse_json_value(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Res
|
|||||||
expect_literal(chars, "false")?;
|
expect_literal(chars, "false")?;
|
||||||
Ok("false".to_string())
|
Ok("false".to_string())
|
||||||
}
|
}
|
||||||
_ => Err(anyhow!("Unsupported JSON value in LinkPlay status")),
|
_ => Err(ControlPointError::ArilycTcpError(format!("Unsupported JSON value in LinkPlay status"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_json_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
|
fn parse_json_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String, ControlPointError> {
|
||||||
if chars.next() != Some('"') {
|
if chars.next() != Some('"') {
|
||||||
return Err(anyhow!("Expected string"));
|
return Err(ControlPointError::ArilycTcpError(format!("Expected string")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
@@ -360,7 +350,7 @@ fn parse_json_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Re
|
|||||||
match ch {
|
match ch {
|
||||||
'"' => return Ok(out),
|
'"' => return Ok(out),
|
||||||
'\\' => {
|
'\\' => {
|
||||||
let escaped = chars.next().ok_or_else(|| anyhow!("Invalid escape"))?;
|
let escaped = chars.next().ok_or_else(|| ControlPointError::ArilycTcpError(format!("Invalid escape")))?;
|
||||||
match escaped {
|
match escaped {
|
||||||
'"' => out.push('"'),
|
'"' => out.push('"'),
|
||||||
'\\' => out.push('\\'),
|
'\\' => out.push('\\'),
|
||||||
@@ -373,28 +363,28 @@ fn parse_json_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Re
|
|||||||
'u' => {
|
'u' => {
|
||||||
let mut hex = String::with_capacity(4);
|
let mut hex = String::with_capacity(4);
|
||||||
for _ in 0..4 {
|
for _ in 0..4 {
|
||||||
let h = chars.next().ok_or_else(|| anyhow!("Invalid \\u escape"))?;
|
let h = chars.next().ok_or_else(|| ControlPointError::ArilycTcpError(format!("Invalid \\u escape")))?;
|
||||||
hex.push(h);
|
hex.push(h);
|
||||||
}
|
}
|
||||||
let code = u16::from_str_radix(&hex, 16)
|
let code = u16::from_str_radix(&hex, 16)
|
||||||
.with_context(|| format!("Invalid unicode escape: {}", hex))?;
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("Invalid unicode escape: {}", hex)))?;
|
||||||
if let Some(c) = char::from_u32(code as u32) {
|
if let Some(c) = char::from_u32(code as u32) {
|
||||||
out.push(c);
|
out.push(c);
|
||||||
} else {
|
} else {
|
||||||
return Err(anyhow!("Invalid unicode code point: {}", code));
|
return Err(ControlPointError::ArilycTcpError(format!("Invalid unicode code point: {}", code)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
other => return Err(anyhow!("Unsupported escape: {}", other)),
|
other => return Err(ControlPointError::ArilycTcpError(format!("Unsupported escape: {}", other))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
other => out.push(other),
|
other => out.push(other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!("Unterminated JSON string"))
|
Err(ControlPointError::ArilycTcpError(format!("Unterminated JSON string")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_json_number(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
|
fn parse_json_number(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String, ControlPointError> {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|
||||||
if matches!(chars.peek(), Some('-')) {
|
if matches!(chars.peek(), Some('-')) {
|
||||||
@@ -412,7 +402,7 @@ fn parse_json_number(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
if out.is_empty() || out == "-" {
|
if out.is_empty() || out == "-" {
|
||||||
return Err(anyhow!("Invalid number"));
|
return Err(ControlPointError::ArilycTcpError(format!("Invalid number")));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -421,20 +411,20 @@ fn parse_json_number(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Re
|
|||||||
fn expect_literal(
|
fn expect_literal(
|
||||||
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
|
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
|
||||||
literal: &str,
|
literal: &str,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
for expected in literal.chars() {
|
for expected in literal.chars() {
|
||||||
match chars.next() {
|
match chars.next() {
|
||||||
Some(ch) if ch == expected => {}
|
Some(ch) if ch == expected => {}
|
||||||
_ => return Err(anyhow!("Invalid literal while parsing JSON")),
|
_ => return Err(ControlPointError::ArilycTcpError(format!("Invalid literal while parsing JSON"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expect_char(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, expected: char) -> Result<()> {
|
fn expect_char(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, expected: char) -> Result<(), ControlPointError> {
|
||||||
match chars.next() {
|
match chars.next() {
|
||||||
Some(ch) if ch == expected => Ok(()),
|
Some(ch) if ch == expected => Ok(()),
|
||||||
_ => Err(anyhow!("Missing '{}' while parsing JSON", expected)),
|
_ => Err(ControlPointError::ArilycTcpError(format!("Missing '{}' while parsing JSON", expected))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,36 +1,263 @@
|
|||||||
use std::time::{Duration, SystemTime};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use pmodidl::DIDLLite;
|
||||||
use pmodidl::{self, DIDLLite};
|
|
||||||
use pmoupnp::soap::SoapEnvelope;
|
use pmoupnp::soap::SoapEnvelope;
|
||||||
use pmoupnp::soap::error_codes;
|
use pmoupnp::soap::error_codes;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
use crate::queue_backend::PlaybackItem;
|
use crate::online::{DeviceConnectionState, DeviceOnline};
|
||||||
|
use crate::queue::backend::PlaybackItem;
|
||||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
||||||
|
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity};
|
||||||
/// Unique identifier for a media server registered by the control point.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
|
||||||
pub struct ServerId(pub String);
|
|
||||||
|
|
||||||
/// Snapshot of a media server discovered through UPnP SSDP.
|
/// Snapshot of a media server discovered through UPnP SSDP.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct MediaServerInfo {
|
pub struct UpnpMediaServer {
|
||||||
pub id: ServerId,
|
id: DeviceId,
|
||||||
pub udn: String,
|
udn: String,
|
||||||
pub friendly_name: String,
|
friendly_name: String,
|
||||||
pub model_name: String,
|
model_name: String,
|
||||||
pub manufacturer: String,
|
manufacturer: String,
|
||||||
pub location: String,
|
location: String,
|
||||||
pub server_header: String,
|
server_header: String,
|
||||||
pub online: bool,
|
has_content_directory: bool,
|
||||||
pub last_seen: SystemTime,
|
content_directory_service_type: Option<String>,
|
||||||
pub max_age: u32,
|
content_directory_control_url: Option<String>,
|
||||||
pub has_content_directory: bool,
|
connection: Arc<Mutex<DeviceConnectionState>>,
|
||||||
pub content_directory_service_type: Option<String>,
|
}
|
||||||
pub content_directory_control_url: Option<String>,
|
|
||||||
|
impl UpnpMediaServer {
|
||||||
|
pub fn make(
|
||||||
|
id: DeviceId,
|
||||||
|
udn: String,
|
||||||
|
friendly_name: String,
|
||||||
|
model_name: String,
|
||||||
|
manufacturer: String,
|
||||||
|
location: String,
|
||||||
|
server_header: String,
|
||||||
|
has_content_directory: bool,
|
||||||
|
content_directory_service_type: Option<String>,
|
||||||
|
content_directory_control_url: Option<String>,
|
||||||
|
) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
id,
|
||||||
|
udn,
|
||||||
|
friendly_name,
|
||||||
|
model_name,
|
||||||
|
manufacturer,
|
||||||
|
location,
|
||||||
|
server_header,
|
||||||
|
has_content_directory,
|
||||||
|
content_directory_service_type,
|
||||||
|
content_directory_control_url,
|
||||||
|
connection: DeviceConnectionState::make(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browse_with_flag(
|
||||||
|
&self,
|
||||||
|
object_id: &str,
|
||||||
|
browse_flag: &str,
|
||||||
|
start: u32,
|
||||||
|
count: u32,
|
||||||
|
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||||
|
let start_str = start.to_string();
|
||||||
|
let count_str = count.to_string();
|
||||||
|
let args = vec![
|
||||||
|
("ObjectID", object_id.to_string()),
|
||||||
|
("BrowseFlag", browse_flag.to_string()),
|
||||||
|
("Filter", "*".to_string()),
|
||||||
|
("StartingIndex", start_str),
|
||||||
|
("RequestedCount", count_str),
|
||||||
|
("SortCriteria", String::new()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let response = self.invoke_content_directory("Browse", None, args)?;
|
||||||
|
let envelope = response.envelope.ok_or_else(|| {
|
||||||
|
ControlPointError::MediaServerError(format!("Missing SOAP envelope in Browse response"))
|
||||||
|
})?;
|
||||||
|
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
||||||
|
map_didl_entries(&didl_xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_impl(
|
||||||
|
&self,
|
||||||
|
container_id: &str,
|
||||||
|
query: &str,
|
||||||
|
start: u32,
|
||||||
|
count: u32,
|
||||||
|
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||||
|
let start_str = start.to_string();
|
||||||
|
let count_str = count.to_string();
|
||||||
|
|
||||||
|
let args = vec![
|
||||||
|
("ContainerID", container_id.to_string()),
|
||||||
|
("SearchCriteria", query.to_string()),
|
||||||
|
("Filter", "*".to_string()),
|
||||||
|
("StartingIndex", start_str),
|
||||||
|
("RequestedCount", count_str),
|
||||||
|
("SortCriteria", String::new()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let response = self.invoke_content_directory("Search", Some("search"), args)?;
|
||||||
|
let envelope = response.envelope.ok_or_else(|| {
|
||||||
|
ControlPointError::MediaServerError(format!("Missing SOAP envelope in Search response"))
|
||||||
|
})?;
|
||||||
|
let didl_xml = extract_result_payload(&envelope, "SearchResponse")?;
|
||||||
|
map_didl_entries(&didl_xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invoke_content_directory(
|
||||||
|
&self,
|
||||||
|
action: &str,
|
||||||
|
op_name: Option<&str>,
|
||||||
|
args: Vec<(&'static str, String)>,
|
||||||
|
) -> Result<SoapCallResult, ControlPointError> {
|
||||||
|
let op = op_name.unwrap_or(action);
|
||||||
|
let (control_url, service_type) = self.content_directory_endpoints(op)?;
|
||||||
|
let borrowed_args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||||
|
|
||||||
|
let call_result = invoke_upnp_action_with_timeout(
|
||||||
|
control_url,
|
||||||
|
service_type,
|
||||||
|
action,
|
||||||
|
&borrowed_args,
|
||||||
|
Some(DEFAULT_HTTP_TIMEOUT),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if !call_result.status.is_success() {
|
||||||
|
if let Some(env) = &call_result.envelope {
|
||||||
|
if let Some(err) = parse_upnp_error(env) {
|
||||||
|
if should_map_to_not_supported(action, op_name, err.error_code) {
|
||||||
|
return Err(ControlPointError::upnp_operation_not_supported(
|
||||||
|
op,
|
||||||
|
"UpnpMediaServer",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(ControlPointError::MediaServerError(format!(
|
||||||
|
"{} failed with UPnP error {}: {}",
|
||||||
|
action, err.error_code, err.error_description
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(ControlPointError::MediaServerError(format!(
|
||||||
|
"{} failed with HTTP status {} and body: {}",
|
||||||
|
action, call_result.status, call_result.raw_body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(env) = &call_result.envelope {
|
||||||
|
if let Some(err) = parse_upnp_error(env) {
|
||||||
|
if should_map_to_not_supported(action, op_name, err.error_code) {
|
||||||
|
return Err(ControlPointError::upnp_operation_not_supported(
|
||||||
|
op,
|
||||||
|
"UpnpMediaServer",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(ControlPointError::MediaServerError(format!(
|
||||||
|
"{} returned UPnP error {}: {}",
|
||||||
|
action, err.error_code, err.error_description
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(call_result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_directory_endpoints(
|
||||||
|
&self,
|
||||||
|
op_name: &str,
|
||||||
|
) -> Result<(&str, &str), ControlPointError> {
|
||||||
|
if !self.has_content_directory {
|
||||||
|
return Err(ControlPointError::upnp_operation_not_supported(
|
||||||
|
op_name,
|
||||||
|
"UpnpMediaServer",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let control_url = self
|
||||||
|
.content_directory_control_url
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ControlPointError::upnp_operation_not_supported(op_name, "UpnpMediaServer")
|
||||||
|
})?;
|
||||||
|
let service_type = self
|
||||||
|
.content_directory_service_type
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ControlPointError::upnp_operation_not_supported(op_name, "UpnpMediaServer")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((control_url, service_type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceOnline for UpnpMediaServer {
|
||||||
|
fn is_online(&self) -> bool {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection Mutex Poisoned")
|
||||||
|
.is_online()
|
||||||
|
}
|
||||||
|
fn last_seen(&self) -> SystemTime {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection Mutex Poisoned")
|
||||||
|
.last_seen()
|
||||||
|
}
|
||||||
|
fn has_been_seen_now(&self, max_age: u32) {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection Mutex Poisoned")
|
||||||
|
.has_been_seen_now(max_age)
|
||||||
|
}
|
||||||
|
fn mark_as_offline(&self) {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection Mutex Poisoned")
|
||||||
|
.mark_as_offline()
|
||||||
|
}
|
||||||
|
fn max_age(&self) -> u32 {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection Mutex Poisoned")
|
||||||
|
.max_age()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceIdentity for UpnpMediaServer {
|
||||||
|
fn id(&self) -> DeviceId {
|
||||||
|
self.id.clone()
|
||||||
|
}
|
||||||
|
fn udn(&self) -> &str {
|
||||||
|
&self.udn
|
||||||
|
}
|
||||||
|
fn friendly_name(&self) -> &str {
|
||||||
|
&self.friendly_name
|
||||||
|
}
|
||||||
|
fn model_name(&self) -> &str {
|
||||||
|
&self.model_name
|
||||||
|
}
|
||||||
|
fn manufacturer(&self) -> &str {
|
||||||
|
&self.manufacturer
|
||||||
|
}
|
||||||
|
fn location(&self) -> &str {
|
||||||
|
&self.location
|
||||||
|
}
|
||||||
|
fn server_header(&self) -> &str {
|
||||||
|
&self.server_header
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_a_media_server(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simplified view over a DIDL-Lite resource entry.
|
/// Simplified view over a DIDL-Lite resource entry.
|
||||||
@@ -54,12 +281,8 @@ impl MediaResource {
|
|||||||
// List of known audio format subtypes (the part after the /)
|
// List of known audio format subtypes (the part after the /)
|
||||||
// These are recognized regardless of the MIME type prefix
|
// These are recognized regardless of the MIME type prefix
|
||||||
const AUDIO_FORMATS: &[&str] = &[
|
const AUDIO_FORMATS: &[&str] = &[
|
||||||
"flac", "ogg", "opus", "vorbis",
|
"flac", "ogg", "opus", "vorbis", "mp3", "mpeg", "mp4", "m4a", "aac", "wav", "wave",
|
||||||
"mp3", "mpeg", "mp4", "m4a", "aac",
|
"pcm", "wma", "webm", "ape", "alac", "aiff", "dsd", "dsf", "dff",
|
||||||
"wav", "wave", "pcm",
|
|
||||||
"wma", "webm",
|
|
||||||
"ape", "alac", "aiff",
|
|
||||||
"dsd", "dsf", "dff",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check if any known audio format appears in the protocol_info
|
// Check if any known audio format appears in the protocol_info
|
||||||
@@ -111,16 +334,21 @@ pub struct MediaEntry {
|
|||||||
|
|
||||||
/// Backend-agnostic media browsing contract.
|
/// Backend-agnostic media browsing contract.
|
||||||
pub trait MediaBrowser {
|
pub trait MediaBrowser {
|
||||||
fn browse_root(&self) -> Result<Vec<MediaEntry>>;
|
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||||
fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result<Vec<MediaEntry>>;
|
fn browse_children(
|
||||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry>;
|
&self,
|
||||||
|
object_id: &str,
|
||||||
|
start: u32,
|
||||||
|
count: u32,
|
||||||
|
) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||||
|
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
|
||||||
fn search(
|
fn search(
|
||||||
&self,
|
&self,
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
query: &str,
|
query: &str,
|
||||||
start: u32,
|
start: u32,
|
||||||
count: u32,
|
count: u32,
|
||||||
) -> Result<Vec<MediaEntry>>;
|
) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Façade over every supported media server backend.
|
/// Façade over every supported media server backend.
|
||||||
@@ -129,41 +357,96 @@ pub enum MusicServer {
|
|||||||
Upnp(UpnpMediaServer),
|
Upnp(UpnpMediaServer),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MusicServer {
|
impl DeviceOnline for MusicServer {
|
||||||
pub fn from_info(info: &MediaServerInfo, timeout: Duration) -> Result<Self> {
|
fn is_online(&self) -> bool {
|
||||||
Ok(MusicServer::Upnp(UpnpMediaServer::new(
|
|
||||||
info.clone(),
|
|
||||||
timeout,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn id(&self) -> &ServerId {
|
|
||||||
match self {
|
match self {
|
||||||
MusicServer::Upnp(upnp) => upnp.id(),
|
MusicServer::Upnp(u) => u.is_online(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn last_seen(&self) -> SystemTime {
|
||||||
pub fn info(&self) -> &MediaServerInfo {
|
|
||||||
match self {
|
match self {
|
||||||
MusicServer::Upnp(upnp) => upnp.info(),
|
MusicServer::Upnp(u) => u.last_seen(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn has_been_seen_now(&self, max_age: u32) {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.has_been_seen_now(max_age),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn mark_as_offline(&self) {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.mark_as_offline(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn max_age(&self) -> u32 {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.max_age(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DeviceIdentity for MusicServer {
|
||||||
|
fn id(&self) -> DeviceId {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.id(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn udn(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.udn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn friendly_name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.friendly_name(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn model_name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.model_name(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn manufacturer(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.manufacturer(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn location(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.location(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn server_header(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
MusicServer::Upnp(u) => u.server_header(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_a_media_server(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl MediaBrowser for MusicServer {
|
impl MediaBrowser for MusicServer {
|
||||||
fn browse_root(&self) -> Result<Vec<MediaEntry>> {
|
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicServer::Upnp(upnp) => upnp.browse_root(),
|
MusicServer::Upnp(upnp) => upnp.browse_root(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result<Vec<MediaEntry>> {
|
fn browse_children(
|
||||||
|
&self,
|
||||||
|
object_id: &str,
|
||||||
|
start: u32,
|
||||||
|
count: u32,
|
||||||
|
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicServer::Upnp(upnp) => upnp.browse_children(object_id, start, count),
|
MusicServer::Upnp(upnp) => upnp.browse_children(object_id, start, count),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry> {
|
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicServer::Upnp(upnp) => upnp.browse_object(object_id),
|
MusicServer::Upnp(upnp) => upnp.browse_object(object_id),
|
||||||
}
|
}
|
||||||
@@ -175,7 +458,7 @@ impl MediaBrowser for MusicServer {
|
|||||||
query: &str,
|
query: &str,
|
||||||
start: u32,
|
start: u32,
|
||||||
count: u32,
|
count: u32,
|
||||||
) -> Result<Vec<MediaEntry>> {
|
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicServer::Upnp(upnp) => upnp.search(container_id, query, start, count),
|
MusicServer::Upnp(upnp) => upnp.search(container_id, query, start, count),
|
||||||
}
|
}
|
||||||
@@ -186,7 +469,10 @@ impl MediaBrowser for MusicServer {
|
|||||||
///
|
///
|
||||||
/// This function filters out containers and entries without audio resources,
|
/// This function filters out containers and entries without audio resources,
|
||||||
/// returning None for items that cannot be played.
|
/// returning None for items that cannot be played.
|
||||||
pub fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
pub fn playback_item_from_entry(
|
||||||
|
server: &UpnpMediaServer,
|
||||||
|
entry: &MediaEntry,
|
||||||
|
) -> Option<PlaybackItem> {
|
||||||
// Ignore containers
|
// Ignore containers
|
||||||
if entry.is_container {
|
if entry.is_container {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -249,6 +535,7 @@ pub fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Opt
|
|||||||
|
|
||||||
Some(PlaybackItem {
|
Some(PlaybackItem {
|
||||||
media_server_id: server.id().clone(),
|
media_server_id: server.id().clone(),
|
||||||
|
backend_id: usize::MAX,
|
||||||
didl_id: entry.id.clone(),
|
didl_id: entry.id.clone(),
|
||||||
uri: resource.uri.clone(),
|
uri: resource.uri.clone(),
|
||||||
protocol_info: resource.protocol_info.clone(),
|
protocol_info: resource.protocol_info.clone(),
|
||||||
@@ -256,174 +543,28 @@ pub fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Opt
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single UPnP ContentDirectory backend implementation.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct UpnpMediaServer {
|
|
||||||
info: MediaServerInfo,
|
|
||||||
timeout: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UpnpMediaServer {
|
|
||||||
pub fn new(info: MediaServerInfo, timeout: Duration) -> Self {
|
|
||||||
Self { info, timeout }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn id(&self) -> &ServerId {
|
|
||||||
&self.info.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn info(&self) -> &MediaServerInfo {
|
|
||||||
&self.info
|
|
||||||
}
|
|
||||||
|
|
||||||
fn browse_with_flag(
|
|
||||||
&self,
|
|
||||||
object_id: &str,
|
|
||||||
browse_flag: &str,
|
|
||||||
start: u32,
|
|
||||||
count: u32,
|
|
||||||
) -> Result<Vec<MediaEntry>> {
|
|
||||||
let start_str = start.to_string();
|
|
||||||
let count_str = count.to_string();
|
|
||||||
let args = vec![
|
|
||||||
("ObjectID", object_id.to_string()),
|
|
||||||
("BrowseFlag", browse_flag.to_string()),
|
|
||||||
("Filter", "*".to_string()),
|
|
||||||
("StartingIndex", start_str),
|
|
||||||
("RequestedCount", count_str),
|
|
||||||
("SortCriteria", String::new()),
|
|
||||||
];
|
|
||||||
|
|
||||||
let response = self.invoke_content_directory("Browse", None, args)?;
|
|
||||||
let envelope = response
|
|
||||||
.envelope
|
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in Browse response"))?;
|
|
||||||
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
|
||||||
map_didl_entries(&didl_xml)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search_impl(
|
|
||||||
&self,
|
|
||||||
container_id: &str,
|
|
||||||
query: &str,
|
|
||||||
start: u32,
|
|
||||||
count: u32,
|
|
||||||
) -> Result<Vec<MediaEntry>> {
|
|
||||||
let start_str = start.to_string();
|
|
||||||
let count_str = count.to_string();
|
|
||||||
|
|
||||||
let args = vec![
|
|
||||||
("ContainerID", container_id.to_string()),
|
|
||||||
("SearchCriteria", query.to_string()),
|
|
||||||
("Filter", "*".to_string()),
|
|
||||||
("StartingIndex", start_str),
|
|
||||||
("RequestedCount", count_str),
|
|
||||||
("SortCriteria", String::new()),
|
|
||||||
];
|
|
||||||
|
|
||||||
let response = self.invoke_content_directory("Search", Some("search"), args)?;
|
|
||||||
let envelope = response
|
|
||||||
.envelope
|
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in Search response"))?;
|
|
||||||
let didl_xml = extract_result_payload(&envelope, "SearchResponse")?;
|
|
||||||
map_didl_entries(&didl_xml)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invoke_content_directory(
|
|
||||||
&self,
|
|
||||||
action: &str,
|
|
||||||
op_name: Option<&str>,
|
|
||||||
args: Vec<(&'static str, String)>,
|
|
||||||
) -> Result<SoapCallResult> {
|
|
||||||
let op = op_name.unwrap_or(action);
|
|
||||||
let (control_url, service_type) = self.content_directory_endpoints(op)?;
|
|
||||||
let borrowed_args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
|
||||||
|
|
||||||
let call_result = invoke_upnp_action_with_timeout(
|
|
||||||
control_url,
|
|
||||||
service_type,
|
|
||||||
action,
|
|
||||||
&borrowed_args,
|
|
||||||
Some(self.timeout),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
if !call_result.status.is_success() {
|
|
||||||
if let Some(env) = &call_result.envelope {
|
|
||||||
if let Some(err) = parse_upnp_error(env) {
|
|
||||||
if should_map_to_not_supported(action, op_name, err.error_code) {
|
|
||||||
return Err(server_op_not_supported(op, "UpnpMediaServer"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{} failed with UPnP error {}: {}",
|
|
||||||
action,
|
|
||||||
err.error_code,
|
|
||||||
err.error_description
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{} failed with HTTP status {} and body: {}",
|
|
||||||
action,
|
|
||||||
call_result.status,
|
|
||||||
call_result.raw_body
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(env) = &call_result.envelope {
|
|
||||||
if let Some(err) = parse_upnp_error(env) {
|
|
||||||
if should_map_to_not_supported(action, op_name, err.error_code) {
|
|
||||||
return Err(server_op_not_supported(op, "UpnpMediaServer"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{} returned UPnP error {}: {}",
|
|
||||||
action,
|
|
||||||
err.error_code,
|
|
||||||
err.error_description
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(call_result)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn content_directory_endpoints(&self, op_name: &str) -> Result<(&str, &str)> {
|
|
||||||
if !self.info.has_content_directory {
|
|
||||||
return Err(server_op_not_supported(op_name, "UpnpMediaServer"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let control_url = self
|
|
||||||
.info
|
|
||||||
.content_directory_control_url
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| server_op_not_supported(op_name, "UpnpMediaServer"))?;
|
|
||||||
let service_type = self
|
|
||||||
.info
|
|
||||||
.content_directory_service_type
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| server_op_not_supported(op_name, "UpnpMediaServer"))?;
|
|
||||||
|
|
||||||
Ok((control_url, service_type))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MediaBrowser for UpnpMediaServer {
|
impl MediaBrowser for UpnpMediaServer {
|
||||||
fn browse_root(&self) -> Result<Vec<MediaEntry>> {
|
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||||
self.browse_with_flag("0", "BrowseDirectChildren", 0, 0)
|
self.browse_with_flag("0", "BrowseDirectChildren", 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result<Vec<MediaEntry>> {
|
fn browse_children(
|
||||||
|
&self,
|
||||||
|
object_id: &str,
|
||||||
|
start: u32,
|
||||||
|
count: u32,
|
||||||
|
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||||
self.browse_with_flag(object_id, "BrowseDirectChildren", start, count)
|
self.browse_with_flag(object_id, "BrowseDirectChildren", start, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry> {
|
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
|
||||||
let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?;
|
let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?;
|
||||||
entries
|
entries.into_iter().next().ok_or_else(|| {
|
||||||
.into_iter()
|
ControlPointError::MediaServerError(format!(
|
||||||
.next()
|
"Object {} was not returned by the server",
|
||||||
.ok_or_else(|| anyhow!("Object {} was not returned by the server", object_id))
|
object_id
|
||||||
|
))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn search(
|
fn search(
|
||||||
@@ -432,19 +573,24 @@ impl MediaBrowser for UpnpMediaServer {
|
|||||||
query: &str,
|
query: &str,
|
||||||
start: u32,
|
start: u32,
|
||||||
count: u32,
|
count: u32,
|
||||||
) -> Result<Vec<MediaEntry>> {
|
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||||
self.search_impl(container_id, query, start, count)
|
self.search_impl(container_id, query, start, count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>> {
|
fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||||
let trimmed = xml.trim();
|
let trimmed = xml.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let didl: DIDLLite = pmodidl::parse_metadata::<DIDLLite>(trimmed)
|
let didl: DIDLLite = pmodidl::parse_metadata::<DIDLLite>(trimmed)
|
||||||
.map_err(|err| anyhow!("Failed to parse DIDL-Lite payload: {}", err))?
|
.map_err(|err| {
|
||||||
|
ControlPointError::MediaServerError(format!(
|
||||||
|
"Failed to parse DIDL-Lite payload: {}",
|
||||||
|
err
|
||||||
|
))
|
||||||
|
})?
|
||||||
.data;
|
.data;
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
@@ -503,11 +649,23 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>> {
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_result_payload(envelope: &SoapEnvelope, response_suffix: &str) -> Result<String> {
|
fn extract_result_payload(
|
||||||
let response = find_child_with_suffix(&envelope.body.content, response_suffix)
|
envelope: &SoapEnvelope,
|
||||||
.ok_or_else(|| anyhow!("Missing {} element in SOAP body", response_suffix))?;
|
response_suffix: &str,
|
||||||
let result_elem = find_child_with_suffix(response, "Result")
|
) -> Result<String, ControlPointError> {
|
||||||
.ok_or_else(|| anyhow!("Missing Result element in {}", response_suffix))?;
|
let response =
|
||||||
|
find_child_with_suffix(&envelope.body.content, response_suffix).ok_or_else(|| {
|
||||||
|
ControlPointError::MediaServerError(format!(
|
||||||
|
"Missing {} element in SOAP body",
|
||||||
|
response_suffix
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let result_elem = find_child_with_suffix(response, "Result").ok_or_else(|| {
|
||||||
|
ControlPointError::MediaServerError(format!(
|
||||||
|
"Missing Result element in {}",
|
||||||
|
response_suffix
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
let payload = result_elem
|
let payload = result_elem
|
||||||
.get_text()
|
.get_text()
|
||||||
@@ -568,14 +726,6 @@ fn should_map_to_not_supported(action: &str, op_name: Option<&str>, error_code:
|
|||||||
cd_not_supported && (error_code == optional_code || error_code == invalid_action_code)
|
cd_not_supported && (error_code == optional_code || error_code == invalid_action_code)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn server_op_not_supported(op: &str, backend: &str) -> anyhow::Error {
|
|
||||||
anyhow!(
|
|
||||||
"MusicServer operation '{}' is not supported by backend '{}'",
|
|
||||||
op,
|
|
||||||
backend
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct UpnpError {
|
struct UpnpError {
|
||||||
pub error_code: u32,
|
pub error_code: u32,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use ureq::{Agent, http};
|
|||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
use crate::events::MediaServerEventBus;
|
use crate::events::MediaServerEventBus;
|
||||||
use crate::media_server::{MediaServerInfo, ServerId};
|
use crate::media_server::{UpnpMediaServer, ServerId};
|
||||||
use crate::model::MediaServerEvent;
|
use crate::model::MediaServerEvent;
|
||||||
use crate::provider::resolve_control_url;
|
use crate::provider::resolve_control_url;
|
||||||
use crate::registry::{DeviceRegistry, DeviceRegistryRead};
|
use crate::registry::{DeviceRegistry, DeviceRegistryRead};
|
||||||
@@ -551,7 +551,7 @@ impl MediaServerEventWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct SubscriptionState {
|
struct SubscriptionState {
|
||||||
info: MediaServerInfo,
|
info: UpnpMediaServer,
|
||||||
event_sub_url: Option<String>,
|
event_sub_url: Option<String>,
|
||||||
sid: Option<String>,
|
sid: Option<String>,
|
||||||
expires_at: Option<Instant>,
|
expires_at: Option<Instant>,
|
||||||
@@ -560,7 +560,7 @@ struct SubscriptionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SubscriptionState {
|
impl SubscriptionState {
|
||||||
fn new(info: MediaServerInfo) -> Self {
|
fn new(info: UpnpMediaServer) -> Self {
|
||||||
Self {
|
Self {
|
||||||
callback_path: build_callback_path(&info.id),
|
callback_path: build_callback_path(&info.id),
|
||||||
info,
|
info,
|
||||||
@@ -571,7 +571,7 @@ impl SubscriptionState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, info: MediaServerInfo) {
|
fn update(&mut self, info: UpnpMediaServer) {
|
||||||
if self.info.location != info.location {
|
if self.info.location != info.location {
|
||||||
self.event_sub_url = None;
|
self.event_sub_url = None;
|
||||||
self.sid = None;
|
self.sid = None;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::{DeviceId,DeviceIdentity};
|
||||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
|
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
|
||||||
use crate::control_point::PlaylistBinding;
|
use crate::control_point::PlaylistBinding;
|
||||||
use crate::media_server::{MediaServerInfo, ServerId};
|
use crate::media_server::UpnpMediaServer;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
||||||
pub struct RendererId(pub String);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct TrackMetadata {
|
pub struct TrackMetadata {
|
||||||
@@ -17,11 +18,11 @@ pub struct TrackMetadata {
|
|||||||
pub creator: Option<String>,
|
pub creator: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Copy)]
|
||||||
pub enum RendererProtocol {
|
pub enum RendererProtocol {
|
||||||
UpnpAvOnly,
|
UpnpAvOnly,
|
||||||
OpenHomeOnly,
|
OpenHomeOnly,
|
||||||
Hybrid,
|
OpenHomeHybrid,
|
||||||
ChromecastOnly,
|
ChromecastOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +48,74 @@ pub struct RendererCapabilities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RendererCapabilities {
|
impl RendererCapabilities {
|
||||||
|
pub fn make(
|
||||||
|
has_avtransport: bool,
|
||||||
|
has_avtransport_set_next: bool,
|
||||||
|
has_rendering_control: bool,
|
||||||
|
has_connection_manager: bool,
|
||||||
|
has_linkplay_http: bool,
|
||||||
|
has_arylic_tcp: bool,
|
||||||
|
has_oh_playlist: bool,
|
||||||
|
has_oh_volume: bool,
|
||||||
|
has_oh_info: bool,
|
||||||
|
has_oh_time: bool,
|
||||||
|
has_oh_radio: bool,
|
||||||
|
has_chromecast: bool,
|
||||||
|
) -> Self {
|
||||||
|
RendererCapabilities {
|
||||||
|
has_avtransport,
|
||||||
|
has_avtransport_set_next,
|
||||||
|
has_rendering_control,
|
||||||
|
has_connection_manager,
|
||||||
|
has_linkplay_http,
|
||||||
|
has_arylic_tcp,
|
||||||
|
has_oh_playlist,
|
||||||
|
has_oh_volume,
|
||||||
|
has_oh_info,
|
||||||
|
has_oh_time,
|
||||||
|
has_oh_radio,
|
||||||
|
has_chromecast,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_avtransport(&self) -> bool {
|
||||||
|
self.has_avtransport
|
||||||
|
}
|
||||||
|
pub fn has_avtransport_set_next(&self) -> bool {
|
||||||
|
self.has_avtransport_set_next
|
||||||
|
}
|
||||||
|
pub fn has_rendering_control(&self) -> bool {
|
||||||
|
self.has_rendering_control
|
||||||
|
}
|
||||||
|
pub fn has_connection_manager(&self) -> bool {
|
||||||
|
self.has_connection_manager
|
||||||
|
}
|
||||||
|
pub fn has_linkplay_http(&self) -> bool {
|
||||||
|
self.has_linkplay_http
|
||||||
|
}
|
||||||
|
pub fn has_arylic_tcp(&self) -> bool {
|
||||||
|
self.has_arylic_tcp
|
||||||
|
}
|
||||||
|
pub fn has_oh_playlist(&self) -> bool {
|
||||||
|
self.has_oh_playlist
|
||||||
|
}
|
||||||
|
pub fn has_oh_volume(&self) -> bool {
|
||||||
|
self.has_oh_volume
|
||||||
|
}
|
||||||
|
pub fn has_oh_info(&self) -> bool {
|
||||||
|
self.has_oh_info
|
||||||
|
}
|
||||||
|
pub fn has_oh_time(&self) -> bool {
|
||||||
|
self.has_oh_time
|
||||||
|
}
|
||||||
|
pub fn has_oh_radio(&self) -> bool {
|
||||||
|
self.has_oh_radio
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_chromecast(&self) -> bool {
|
||||||
|
self.has_chromecast
|
||||||
|
}
|
||||||
|
|
||||||
pub fn supports_set_next(&self) -> bool {
|
pub fn supports_set_next(&self) -> bool {
|
||||||
self.has_avtransport && self.has_avtransport_set_next
|
self.has_avtransport && self.has_avtransport_set_next
|
||||||
}
|
}
|
||||||
@@ -54,98 +123,263 @@ impl RendererCapabilities {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RendererInfo {
|
pub struct RendererInfo {
|
||||||
pub id: RendererId,
|
id: DeviceId,
|
||||||
pub udn: String,
|
udn: String,
|
||||||
pub friendly_name: String,
|
friendly_name: String,
|
||||||
pub model_name: String,
|
model_name: String,
|
||||||
pub manufacturer: String,
|
manufacturer: String,
|
||||||
|
|
||||||
pub protocol: RendererProtocol,
|
protocol: RendererProtocol,
|
||||||
pub capabilities: RendererCapabilities,
|
capabilities: RendererCapabilities,
|
||||||
|
|
||||||
pub location: String,
|
location: String,
|
||||||
pub server_header: String,
|
|
||||||
pub online: bool,
|
|
||||||
pub last_seen: std::time::SystemTime,
|
|
||||||
pub max_age: u32,
|
|
||||||
|
|
||||||
pub avtransport_service_type: Option<String>,
|
server_header: String,
|
||||||
pub avtransport_control_url: Option<String>,
|
avtransport_service_type: Option<String>,
|
||||||
pub rendering_control_service_type: Option<String>,
|
avtransport_control_url: Option<String>,
|
||||||
pub rendering_control_control_url: Option<String>,
|
rendering_control_service_type: Option<String>,
|
||||||
pub connection_manager_service_type: Option<String>,
|
rendering_control_control_url: Option<String>,
|
||||||
pub connection_manager_control_url: Option<String>,
|
connection_manager_service_type: Option<String>,
|
||||||
pub oh_playlist_service_type: Option<String>,
|
connection_manager_control_url: Option<String>,
|
||||||
pub oh_playlist_control_url: Option<String>,
|
oh_playlist_service_type: Option<String>,
|
||||||
pub oh_playlist_event_sub_url: Option<String>,
|
oh_playlist_control_url: Option<String>,
|
||||||
pub oh_info_service_type: Option<String>,
|
oh_playlist_event_sub_url: Option<String>,
|
||||||
pub oh_info_control_url: Option<String>,
|
oh_info_service_type: Option<String>,
|
||||||
pub oh_info_event_sub_url: Option<String>,
|
oh_info_control_url: Option<String>,
|
||||||
pub oh_time_service_type: Option<String>,
|
oh_info_event_sub_url: Option<String>,
|
||||||
pub oh_time_control_url: Option<String>,
|
oh_time_service_type: Option<String>,
|
||||||
pub oh_time_event_sub_url: Option<String>,
|
oh_time_control_url: Option<String>,
|
||||||
pub oh_volume_service_type: Option<String>,
|
oh_time_event_sub_url: Option<String>,
|
||||||
pub oh_volume_control_url: Option<String>,
|
oh_volume_service_type: Option<String>,
|
||||||
pub oh_radio_service_type: Option<String>,
|
oh_volume_control_url: Option<String>,
|
||||||
pub oh_radio_control_url: Option<String>,
|
oh_radio_service_type: Option<String>,
|
||||||
pub oh_product_service_type: Option<String>,
|
oh_radio_control_url: Option<String>,
|
||||||
pub oh_product_control_url: Option<String>,
|
oh_product_service_type: Option<String>,
|
||||||
|
oh_product_control_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RendererInfo {
|
||||||
|
pub fn make(
|
||||||
|
id: DeviceId,
|
||||||
|
udn: String,
|
||||||
|
friendly_name: String,
|
||||||
|
model_name: String,
|
||||||
|
manufacturer: String,
|
||||||
|
protocol: RendererProtocol,
|
||||||
|
capabilities: RendererCapabilities,
|
||||||
|
location: String,
|
||||||
|
server_header: String,
|
||||||
|
avtransport_service_type: Option<String>,
|
||||||
|
avtransport_control_url: Option<String>,
|
||||||
|
rendering_control_service_type: Option<String>,
|
||||||
|
rendering_control_control_url: Option<String>,
|
||||||
|
connection_manager_service_type: Option<String>,
|
||||||
|
connection_manager_control_url: Option<String>,
|
||||||
|
oh_playlist_service_type: Option<String>,
|
||||||
|
oh_playlist_control_url: Option<String>,
|
||||||
|
oh_playlist_event_sub_url: Option<String>,
|
||||||
|
oh_info_service_type: Option<String>,
|
||||||
|
oh_info_control_url: Option<String>,
|
||||||
|
oh_info_event_sub_url: Option<String>,
|
||||||
|
oh_time_service_type: Option<String>,
|
||||||
|
oh_time_control_url: Option<String>,
|
||||||
|
oh_time_event_sub_url: Option<String>,
|
||||||
|
oh_volume_service_type: Option<String>,
|
||||||
|
oh_volume_control_url: Option<String>,
|
||||||
|
oh_radio_service_type: Option<String>,
|
||||||
|
oh_radio_control_url: Option<String>,
|
||||||
|
oh_product_service_type: Option<String>,
|
||||||
|
oh_product_control_url: Option<String>,
|
||||||
|
) -> RendererInfo {
|
||||||
|
RendererInfo {
|
||||||
|
id,
|
||||||
|
udn,
|
||||||
|
friendly_name,
|
||||||
|
model_name,
|
||||||
|
manufacturer,
|
||||||
|
protocol,
|
||||||
|
capabilities,
|
||||||
|
location,
|
||||||
|
server_header,
|
||||||
|
avtransport_service_type,
|
||||||
|
avtransport_control_url,
|
||||||
|
rendering_control_service_type,
|
||||||
|
rendering_control_control_url,
|
||||||
|
connection_manager_service_type,
|
||||||
|
connection_manager_control_url,
|
||||||
|
oh_playlist_service_type,
|
||||||
|
oh_playlist_control_url,
|
||||||
|
oh_playlist_event_sub_url,
|
||||||
|
oh_info_service_type,
|
||||||
|
oh_info_control_url,
|
||||||
|
oh_info_event_sub_url,
|
||||||
|
oh_time_service_type,
|
||||||
|
oh_time_control_url,
|
||||||
|
oh_time_event_sub_url,
|
||||||
|
oh_volume_service_type,
|
||||||
|
oh_volume_control_url,
|
||||||
|
oh_radio_service_type,
|
||||||
|
oh_radio_control_url,
|
||||||
|
oh_product_service_type,
|
||||||
|
oh_product_control_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn protocol(&self) -> RendererProtocol {
|
||||||
|
self.protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capabilities(&self) -> &RendererCapabilities {
|
||||||
|
&self.capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn avtransport_service_type(&self) -> Option<String> {
|
||||||
|
self.avtransport_service_type.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn avtransport_control_url(&self) -> Option<String> {
|
||||||
|
self.avtransport_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn rendering_control_service_type(&self) -> Option<String> {
|
||||||
|
self.rendering_control_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn rendering_control_control_url(&self) -> Option<String> {
|
||||||
|
self.rendering_control_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn connection_manager_service_type(&self) -> Option<String> {
|
||||||
|
self.connection_manager_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn connection_manager_control_url(&self) -> Option<String> {
|
||||||
|
self.connection_manager_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_playlist_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_playlist_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_playlist_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_playlist_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_playlist_event_sub_url(&self) -> Option<String> {
|
||||||
|
self.oh_playlist_event_sub_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_info_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_info_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_info_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_info_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_info_event_sub_url(&self) -> Option<String> {
|
||||||
|
self.oh_info_event_sub_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_time_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_time_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_time_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_time_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_time_event_sub_url(&self) -> Option<String> {
|
||||||
|
self.oh_time_event_sub_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_volume_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_volume_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_volume_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_volume_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_radio_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_radio_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_radio_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_radio_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_product_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_product_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_product_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_product_control_url.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceIdentity for RendererInfo {
|
||||||
|
fn id(&self) -> DeviceId {
|
||||||
|
self.id.clone()
|
||||||
|
}
|
||||||
|
fn udn(&self) -> &str {
|
||||||
|
&self.udn
|
||||||
|
}
|
||||||
|
fn friendly_name(&self) -> &str {
|
||||||
|
&self.friendly_name
|
||||||
|
}
|
||||||
|
fn model_name(&self) -> &str {
|
||||||
|
&self.model_name
|
||||||
|
}
|
||||||
|
fn manufacturer(&self) -> &str {
|
||||||
|
&self.manufacturer
|
||||||
|
}
|
||||||
|
fn location(&self) -> &str {
|
||||||
|
&self.location
|
||||||
|
}
|
||||||
|
fn server_header(&self) -> &str {
|
||||||
|
&self.server_header
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_a_music_renderer(&self) -> bool {true }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum RendererEvent {
|
pub enum RendererEvent {
|
||||||
StateChanged {
|
StateChanged {
|
||||||
id: RendererId,
|
id: DeviceId,
|
||||||
state: PlaybackState,
|
state: PlaybackState,
|
||||||
},
|
},
|
||||||
PositionChanged {
|
PositionChanged {
|
||||||
id: RendererId,
|
id: DeviceId,
|
||||||
position: PlaybackPositionInfo,
|
position: PlaybackPositionInfo,
|
||||||
},
|
},
|
||||||
VolumeChanged {
|
VolumeChanged {
|
||||||
id: RendererId,
|
id: DeviceId,
|
||||||
volume: u16,
|
volume: u16,
|
||||||
},
|
},
|
||||||
MuteChanged {
|
MuteChanged {
|
||||||
id: RendererId,
|
id: DeviceId,
|
||||||
mute: bool,
|
mute: bool,
|
||||||
},
|
},
|
||||||
MetadataChanged {
|
MetadataChanged {
|
||||||
id: RendererId,
|
id: DeviceId,
|
||||||
metadata: TrackMetadata,
|
metadata: TrackMetadata,
|
||||||
},
|
},
|
||||||
QueueUpdated {
|
QueueUpdated {
|
||||||
id: RendererId,
|
id: DeviceId,
|
||||||
queue_length: usize,
|
queue_length: usize,
|
||||||
},
|
},
|
||||||
BindingChanged {
|
BindingChanged {
|
||||||
id: RendererId,
|
id: DeviceId,
|
||||||
binding: Option<PlaylistBinding>,
|
binding: Option<PlaylistBinding>,
|
||||||
},
|
},
|
||||||
Online {
|
Online {
|
||||||
id: RendererId,
|
id: DeviceId,
|
||||||
info: RendererInfo,
|
info: RendererInfo,
|
||||||
},
|
},
|
||||||
Offline {
|
Offline {
|
||||||
id: RendererId,
|
id: DeviceId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum MediaServerEvent {
|
pub enum MediaServerEvent {
|
||||||
GlobalUpdated {
|
GlobalUpdated {
|
||||||
server_id: ServerId,
|
server_id: DeviceId,
|
||||||
system_update_id: Option<u32>,
|
system_update_id: Option<u32>,
|
||||||
},
|
},
|
||||||
ContainersUpdated {
|
ContainersUpdated {
|
||||||
server_id: ServerId,
|
server_id: DeviceId,
|
||||||
container_ids: Vec<String>,
|
container_ids: Vec<String>,
|
||||||
},
|
},
|
||||||
Online {
|
Online {
|
||||||
server_id: ServerId,
|
server_id: DeviceId,
|
||||||
info: MediaServerInfo,
|
info: UpnpMediaServer,
|
||||||
},
|
},
|
||||||
Offline {
|
Offline {
|
||||||
server_id: ServerId,
|
server_id: DeviceId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,27 +6,30 @@
|
|||||||
//! renderers through this type so that transport, volume, and state queries
|
//! renderers through this type so that transport, volume, and state queries
|
||||||
//! stay backend-neutral.
|
//! stay backend-neutral.
|
||||||
|
|
||||||
use std::sync::{Arc, OnceLock, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
||||||
use crate::control_point::RendererRuntimeStateMut;
|
// use crate::control_point::RendererRuntimeStateMut;
|
||||||
use crate::control_point::music_queue::MusicQueue;
|
use crate::control_point::music_queue::MusicQueue;
|
||||||
use crate::control_point::openhome_queue::didl_id_from_metadata;
|
use crate::control_point::openhome_queue::didl_id_from_metadata;
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
use crate::media_server::ServerId;
|
use crate::media_server::ServerId;
|
||||||
use crate::model::{RendererId, RendererInfo, RendererProtocol, TrackMetadata};
|
use crate::model::{
|
||||||
|
RendererConnectionState, ServiceId, RendererInfo, RendererProtocol, TrackMetadata,
|
||||||
|
};
|
||||||
use crate::openhome_client::parse_track_metadata_from_didl;
|
use crate::openhome_client::parse_track_metadata_from_didl;
|
||||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
use crate::queue_backend::{PlaybackItem, QueueSnapshot};
|
use crate::queue_backend::{PlaybackItem, QueueSnapshot};
|
||||||
use crate::{
|
use crate::{
|
||||||
ArylicTcpRenderer, ChromecastRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer,
|
ArylicTcpRenderer, ChromecastRenderer, DeviceIdentity, DeviceOnline, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl
|
||||||
PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl,
|
|
||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum MusicRenderer {
|
pub enum MusicRendererBackend {
|
||||||
/// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl).
|
/// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl).
|
||||||
Upnp(UpnpRenderer),
|
Upnp(UpnpRenderer),
|
||||||
/// Renderer powered by OpenHome services.
|
/// Renderer powered by OpenHome services.
|
||||||
@@ -45,158 +48,280 @@ pub enum MusicRenderer {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a standardized error when an operation is not supported by a backend.
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) fn op_not_supported(op: &str, backend: &str) -> anyhow::Error {
|
pub struct MusicRenderer {
|
||||||
anyhow!(
|
info: RendererInfo,
|
||||||
"MusicRenderer operation '{}' is not supported by backend '{}'",
|
connection: Arc<Mutex<RendererConnectionState>>,
|
||||||
op,
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||||
backend
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct RendererRuntimeState {
|
|
||||||
pub queue: MusicQueue,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait OpenHomeQueueProvider: Send + Sync + 'static {
|
|
||||||
fn renderer_state(&self, renderer_id: &RendererId) -> Result<RendererRuntimeState>;
|
|
||||||
fn renderer_state_mut<'a>(
|
|
||||||
&'a self,
|
|
||||||
renderer_id: &RendererId,
|
|
||||||
) -> Result<RendererRuntimeStateMut<'a>>;
|
|
||||||
fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> Result<()>;
|
|
||||||
}
|
|
||||||
|
|
||||||
static OPENHOME_QUEUE_PROVIDER: OnceLock<Arc<dyn OpenHomeQueueProvider>> = OnceLock::new();
|
|
||||||
|
|
||||||
pub fn set_openhome_queue_provider(provider: Arc<dyn OpenHomeQueueProvider>) {
|
|
||||||
let _ = OPENHOME_QUEUE_PROVIDER.set(provider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MusicRenderer {
|
impl MusicRenderer {
|
||||||
/// Renderer identifier (stable within the registry).
|
pub fn new(
|
||||||
pub fn id(&self) -> &RendererId {
|
info: RendererInfo,
|
||||||
match self {
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.id(),
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
MusicRenderer::OpenHome(r) => r.id(),
|
) -> Arc<MusicRenderer> {
|
||||||
MusicRenderer::Upnp(r) => r.id(),
|
let connection = RendererConnectionState::new();
|
||||||
MusicRenderer::LinkPlay(r) => r.id(),
|
|
||||||
MusicRenderer::ArylicTcp(r) => r.id(),
|
let renderer = MusicRenderer {
|
||||||
MusicRenderer::Chromecast(r) => r.id(),
|
info,
|
||||||
}
|
connection,
|
||||||
|
backend,
|
||||||
|
queue,
|
||||||
|
};
|
||||||
|
|
||||||
|
Arc::new(renderer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Human-friendly name reported by the device.
|
pub fn from_renderer_info(info: RendererInfo) -> Result<Arc<MusicRenderer>,ControlPointError> {
|
||||||
pub fn friendly_name(&self) -> &str {
|
let connection = RendererConnectionState::new();
|
||||||
match self {
|
let backend = MusicRendererBackend::from_renderer_info(info)?;
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.friendly_name(),
|
let queue = Mutex::new(MusicQueue::new());
|
||||||
MusicRenderer::OpenHome(r) => r.friendly_name(),
|
|
||||||
MusicRenderer::Upnp(r) => r.friendly_name(),
|
let renderer = Arc::new(MusicRenderer { info, connection, backend, queue });
|
||||||
MusicRenderer::LinkPlay(r) => r.friendly_name(),
|
Ok(renderer)
|
||||||
MusicRenderer::ArylicTcp(r) => r.friendly_name(),
|
|
||||||
MusicRenderer::Chromecast(r) => r.friendly_name(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Protocol classification (UPnP AV only, OpenHome only, hybrid).
|
|
||||||
pub fn protocol(&self) -> &RendererProtocol {
|
|
||||||
&self.info().protocol
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full static info as stored in the registry.
|
|
||||||
pub fn info(&self) -> &RendererInfo {
|
pub fn info(&self) -> &RendererInfo {
|
||||||
match self {
|
&self.info
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => &arylic.info,
|
}
|
||||||
MusicRenderer::OpenHome(r) => &r.info,
|
|
||||||
MusicRenderer::Upnp(r) => &r.info,
|
/// Returns the protocol.
|
||||||
MusicRenderer::LinkPlay(r) => &r.info,
|
fn protocol(&self) -> RendererProtocol {
|
||||||
MusicRenderer::ArylicTcp(r) => &r.info,
|
self.info.protocol()
|
||||||
MusicRenderer::Chromecast(r) => &r.info,
|
}
|
||||||
|
|
||||||
|
fn is_upnp(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::Upnp(_) => true,
|
||||||
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a reference to the underlying UPnP backend, if any.
|
fn is_openhome(&self) -> bool {
|
||||||
pub fn as_upnp(&self) -> Option<&UpnpRenderer> {
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
match self {
|
MusicRendererBackend::OpenHome(_) => true,
|
||||||
MusicRenderer::Upnp(r) => Some(r),
|
_ => false,
|
||||||
_ => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_linkplay(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::LinkPlay(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_arylictcp(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::ArylicTcp(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_chromecast(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::Chromecast(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hybridupnparylic(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { .. } => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this renderer is known to support SetNextAVTransportURI.
|
||||||
|
pub fn supports_set_next(&self) -> bool {
|
||||||
|
self.info.capabilities().supports_set_next()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceIdentity for MusicRenderer {
|
||||||
|
fn id(&self) -> ServiceId {
|
||||||
|
self.info.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn udn(&self) -> &str {
|
||||||
|
&*self.info.udn()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn friendly_name(&self) -> &str {
|
||||||
|
&self.info.friendly_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_name(&self) -> &str {
|
||||||
|
&self.info.model_name()
|
||||||
|
}
|
||||||
|
fn manufacturer(&self) -> &str {
|
||||||
|
&self.info.manufacturer()
|
||||||
|
}
|
||||||
|
fn location(&self) -> &str {
|
||||||
|
&self.info.location()
|
||||||
|
}
|
||||||
|
fn server_header(&self) -> &str {
|
||||||
|
&self.info.server_header()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceOnline for MusicRenderer {
|
||||||
|
fn is_online(&self) -> bool {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.is_online()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_seen(&self) -> SystemTime {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.last_seen()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_been_seen_now(&self, max_age: u32) {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.has_been_seen_now(max_age)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_as_offline(&self) {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.mark_as_offline()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_age(&self) -> u32 {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.max_age()
|
||||||
|
}}
|
||||||
|
// #[derive(Clone, Debug)]
|
||||||
|
// pub struct RendererRuntimeState {
|
||||||
|
// pub queue: MusicQueue,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub trait OpenHomeQueueProvider: Send + Sync + 'static {
|
||||||
|
// fn renderer_state(&self, renderer_id: &RendererId) -> Result<RendererRuntimeState>;
|
||||||
|
// fn renderer_state_mut<'a>(
|
||||||
|
// &'a self,
|
||||||
|
// renderer_id: &RendererId,
|
||||||
|
// ) -> Result<RendererRuntimeStateMut<'a>>;
|
||||||
|
// fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> Result<()>;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// static OPENHOME_QUEUE_PROVIDER: OnceLock<Arc<dyn OpenHomeQueueProvider>> = OnceLock::new();
|
||||||
|
|
||||||
|
// pub fn set_openhome_queue_provider(provider: Arc<dyn OpenHomeQueueProvider>) {
|
||||||
|
// let _ = OPENHOME_QUEUE_PROVIDER.set(provider);
|
||||||
|
// }
|
||||||
|
|
||||||
|
impl MusicRendererBackend {
|
||||||
|
// fn as_backend(&self) -> &dyn MusicRendererBackend {
|
||||||
|
// match self {
|
||||||
|
// MusicRendererBackend::OpenHome(r) => r,
|
||||||
|
// MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic,
|
||||||
|
// MusicRendererBackend::Upnp(r) => r,
|
||||||
|
// MusicRendererBackend::LinkPlay(r) => r,
|
||||||
|
// MusicRendererBackend::ArylicTcp(r) => r,
|
||||||
|
// MusicRendererBackend::Chromecast(r) => r,
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
/// Construct a music renderer from a [`RendererInfo`] and the registry.
|
/// Construct a music renderer from a [`RendererInfo`] and the registry.
|
||||||
///
|
///
|
||||||
/// Returns `None` when no supported backend can be built for this renderer.
|
/// Returns `None` when no supported backend can be built for this renderer.
|
||||||
/// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported)
|
/// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported)
|
||||||
/// or [`MusicRenderer::Upnp`].
|
/// or [`MusicRenderer::Upnp`].
|
||||||
pub fn from_registry_info(
|
pub fn from_renderer_info(
|
||||||
info: RendererInfo,
|
info: RendererInfo,
|
||||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
) -> Result<Arc<Mutex<Self>>, ControlPointError> {
|
||||||
) -> Option<Self> {
|
|
||||||
|
match info.protocol() {
|
||||||
|
RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => {
|
||||||
|
OpenHomeRenderer::from_renderer_info(info.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => Err(ControlPointError::MusicRendererBackendBuild(format!("{:#?}", info.id()))),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Le backend est de type openhome
|
||||||
|
|
||||||
if matches!(
|
if matches!(
|
||||||
info.protocol,
|
info.protocol(),
|
||||||
RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid
|
RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid
|
||||||
) {
|
) {
|
||||||
if let Some(renderer) = {
|
if let Some(renderer) = {
|
||||||
let renderer = OpenHomeRenderer::new(info.clone());
|
let renderer = OpenHomeRenderer::new(info.clone());
|
||||||
renderer.has_any_openhome_service().then_some(renderer)
|
renderer.has_any_openhome_service().then_some(renderer)
|
||||||
} {
|
} {
|
||||||
return Some(MusicRenderer::OpenHome(renderer));
|
return Some(MusicRendererBackend::OpenHome(renderer));
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(info.protocol, RendererProtocol::OpenHomeOnly) {
|
if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) {
|
||||||
warn!(
|
warn!(
|
||||||
renderer = info.friendly_name.as_str(),
|
renderer = info.friendly_name(),
|
||||||
"Renderer advertises OpenHome only but exposes no usable services"
|
"Renderer advertises OpenHome only but exposes no usable services"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(info.protocol, RendererProtocol::ChromecastOnly) {
|
if matches!(info.protocol(), RendererProtocol::ChromecastOnly) {
|
||||||
if let Ok(renderer) = ChromecastRenderer::from_renderer_info(info.clone()) {
|
if let Ok(renderer) = ChromecastRenderer::from_renderer_info(info.clone()) {
|
||||||
return Some(MusicRenderer::Chromecast(renderer));
|
return Some(MusicRendererBackend::Chromecast(renderer));
|
||||||
}
|
}
|
||||||
warn!(
|
warn!(
|
||||||
renderer = info.friendly_name.as_str(),
|
renderer = info.friendly_name(),
|
||||||
"Failed to build Chromecast renderer"
|
"Failed to build Chromecast renderer"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
match info.protocol {
|
match info.protocol() {
|
||||||
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {
|
RendererProtocol::UpnpAvOnly | RendererProtocol::OpenHomeHybrid => {
|
||||||
let has_arylic = info.capabilities.has_arylic_tcp;
|
let has_arylic = info.capabilities().has_arylic_tcp;
|
||||||
let has_avtransport = info.capabilities.has_avtransport;
|
let has_avtransport = info.capabilities().has_avtransport;
|
||||||
|
|
||||||
if has_arylic && has_avtransport {
|
if has_arylic && has_avtransport {
|
||||||
// Construire UpnpRenderer
|
// Construire UpnpRenderer
|
||||||
let upnp = UpnpRenderer::from_registry(info.clone(), registry);
|
let upnp = UpnpRenderer::from_info(&info);
|
||||||
|
|
||||||
// Construire ArylicTcpRenderer
|
// Construire ArylicTcpRenderer
|
||||||
match ArylicTcpRenderer::from_renderer_info(info.clone()) {
|
match ArylicTcpRenderer::from_renderer_info(info.clone()) {
|
||||||
Ok(arylic) => {
|
Ok(arylic) => {
|
||||||
return Some(MusicRenderer::HybridUpnpArylic { upnp, arylic });
|
return Some(MusicRendererBackend::HybridUpnpArylic { upnp, arylic });
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(
|
warn!(
|
||||||
"Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.",
|
"Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.",
|
||||||
info.friendly_name, err
|
info.friendly_name(),
|
||||||
|
err
|
||||||
);
|
);
|
||||||
return Some(MusicRenderer::Upnp(upnp));
|
return Some(MusicRendererBackend::Upnp(upnp));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pas d’Arylic : logique existante
|
// Pas d’Arylic : logique existante
|
||||||
if info.capabilities.has_linkplay_http {
|
if info.capabilities().has_linkplay_http() {
|
||||||
if let Ok(lp) = LinkPlayRenderer::from_renderer_info(info.clone()) {
|
if let Ok(lp) = LinkPlayRenderer::from_renderer_info(info.clone()) {
|
||||||
return Some(MusicRenderer::LinkPlay(lp));
|
return Some(MusicRendererBackend::LinkPlay(lp));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(
|
Some(MusicRendererBackend::Upnp(UpnpRenderer::from_info(
|
||||||
info, registry,
|
&info,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
RendererProtocol::OpenHomeOnly => None,
|
RendererProtocol::OpenHomeOnly => None,
|
||||||
@@ -205,12 +330,8 @@ impl MusicRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||||||
self.fetch_openhome_playlist_snapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn fetch_openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(renderer) => renderer.snapshot_openhome_playlist(),
|
MusicRendererBackend::OpenHome(renderer) => renderer.snapshot_openhome_playlist(),
|
||||||
_ => Err(op_not_supported(
|
_ => Err(op_not_supported(
|
||||||
"openhome_playlist_snapshot",
|
"openhome_playlist_snapshot",
|
||||||
self.unsupported_backend_name(),
|
self.unsupported_backend_name(),
|
||||||
@@ -220,7 +341,7 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
pub fn openhome_playlist_len(&self) -> Result<usize> {
|
pub fn openhome_playlist_len(&self) -> Result<usize> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_len(),
|
MusicRendererBackend::OpenHome(renderer) => renderer.openhome_playlist_len(),
|
||||||
_ => Err(op_not_supported(
|
_ => Err(op_not_supported(
|
||||||
"openhome_playlist_len",
|
"openhome_playlist_len",
|
||||||
self.unsupported_backend_name(),
|
self.unsupported_backend_name(),
|
||||||
@@ -230,7 +351,7 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
pub fn openhome_playlist_ids(&self) -> Result<Vec<u32>> {
|
pub fn openhome_playlist_ids(&self) -> Result<Vec<u32>> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_ids(),
|
MusicRendererBackend::OpenHome(renderer) => renderer.openhome_playlist_ids(),
|
||||||
_ => Err(op_not_supported(
|
_ => Err(op_not_supported(
|
||||||
"openhome_playlist_ids",
|
"openhome_playlist_ids",
|
||||||
self.unsupported_backend_name(),
|
self.unsupported_backend_name(),
|
||||||
@@ -261,7 +382,7 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
pub(crate) fn fetch_openhome_playlist_clear(&self) -> Result<()> {
|
pub(crate) fn fetch_openhome_playlist_clear(&self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(renderer) => renderer.clear_openhome_playlist(),
|
MusicRendererBackend::OpenHome(renderer) => renderer.clear_openhome_playlist(),
|
||||||
_ => Err(op_not_supported(
|
_ => Err(op_not_supported(
|
||||||
"openhome_playlist_clear",
|
"openhome_playlist_clear",
|
||||||
self.unsupported_backend_name(),
|
self.unsupported_backend_name(),
|
||||||
@@ -280,7 +401,7 @@ impl MusicRenderer {
|
|||||||
/// - The renderer is in a clean state ready to receive new content
|
/// - The renderer is in a clean state ready to receive new content
|
||||||
pub fn clear_for_playlist_attach(&self) -> Result<()> {
|
pub fn clear_for_playlist_attach(&self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(_) => {
|
MusicRendererBackend::OpenHome(_) => {
|
||||||
// For OpenHome: clear the playlist (DeleteAll also stops playback automatically)
|
// For OpenHome: clear the playlist (DeleteAll also stops playback automatically)
|
||||||
// then explicitly stop to ensure clean state
|
// then explicitly stop to ensure clean state
|
||||||
self.openhome_playlist_clear()?;
|
self.openhome_playlist_clear()?;
|
||||||
@@ -294,11 +415,11 @@ impl MusicRenderer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
MusicRenderer::Upnp(_)
|
MusicRendererBackend::Upnp(_)
|
||||||
| MusicRenderer::Chromecast(_)
|
| MusicRendererBackend::Chromecast(_)
|
||||||
| MusicRenderer::LinkPlay(_)
|
| MusicRendererBackend::LinkPlay(_)
|
||||||
| MusicRenderer::ArylicTcp(_)
|
| MusicRendererBackend::ArylicTcp(_)
|
||||||
| MusicRenderer::HybridUpnpArylic { .. } => {
|
| MusicRendererBackend::HybridUpnpArylic { .. } => {
|
||||||
// For AVTransport and other single-track renderers: stop playback
|
// For AVTransport and other single-track renderers: stop playback
|
||||||
// This ensures we're not in the middle of playing when we start the new playlist
|
// This ensures we're not in the middle of playing when we start the new playlist
|
||||||
self.stop().or_else(|err| {
|
self.stop().or_else(|err| {
|
||||||
@@ -320,18 +441,18 @@ impl MusicRenderer {
|
|||||||
/// - For Internal queue/AVTransport: no-op (queue is already local)
|
/// - For Internal queue/AVTransport: no-op (queue is already local)
|
||||||
pub fn sync_queue_state(&self) -> Result<()> {
|
pub fn sync_queue_state(&self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(_) => {
|
MusicRendererBackend::OpenHome(_) => {
|
||||||
if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() {
|
if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() {
|
||||||
// Fetch fresh playlist snapshot and update cache
|
// Fetch fresh playlist snapshot and update cache
|
||||||
provider.invalidate_openhome_cache(self.id())?;
|
provider.invalidate_openhome_cache(self.id())?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
MusicRenderer::Upnp(_)
|
MusicRendererBackend::Upnp(_)
|
||||||
| MusicRenderer::Chromecast(_)
|
| MusicRendererBackend::Chromecast(_)
|
||||||
| MusicRenderer::LinkPlay(_)
|
| MusicRendererBackend::LinkPlay(_)
|
||||||
| MusicRenderer::ArylicTcp(_)
|
| MusicRendererBackend::ArylicTcp(_)
|
||||||
| MusicRenderer::HybridUpnpArylic { .. } => {
|
| MusicRendererBackend::HybridUpnpArylic { .. } => {
|
||||||
// No sync needed - queue is local only
|
// No sync needed - queue is local only
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -344,15 +465,15 @@ impl MusicRenderer {
|
|||||||
/// Should be called before queue mutations to ensure clean state.
|
/// Should be called before queue mutations to ensure clean state.
|
||||||
pub fn clear_queue(&self) -> Result<()> {
|
pub fn clear_queue(&self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(_) => {
|
MusicRendererBackend::OpenHome(_) => {
|
||||||
// For OpenHome: clear the playlist on the renderer itself
|
// For OpenHome: clear the playlist on the renderer itself
|
||||||
self.openhome_playlist_clear()
|
self.openhome_playlist_clear()
|
||||||
}
|
}
|
||||||
MusicRenderer::Upnp(_)
|
MusicRendererBackend::Upnp(_)
|
||||||
| MusicRenderer::Chromecast(_)
|
| MusicRendererBackend::Chromecast(_)
|
||||||
| MusicRenderer::LinkPlay(_)
|
| MusicRendererBackend::LinkPlay(_)
|
||||||
| MusicRenderer::ArylicTcp(_)
|
| MusicRendererBackend::ArylicTcp(_)
|
||||||
| MusicRenderer::HybridUpnpArylic { .. } => {
|
| MusicRendererBackend::HybridUpnpArylic { .. } => {
|
||||||
// For other renderers: no persistent queue to clear
|
// For other renderers: no persistent queue to clear
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -371,15 +492,15 @@ impl MusicRenderer {
|
|||||||
play: bool,
|
play: bool,
|
||||||
) -> Result<Option<u32>> {
|
) -> Result<Option<u32>> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(_) => {
|
MusicRendererBackend::OpenHome(_) => {
|
||||||
let track_id = self.openhome_playlist_add_track(uri, metadata, after_id, play)?;
|
let track_id = self.openhome_playlist_add_track(uri, metadata, after_id, play)?;
|
||||||
Ok(Some(track_id))
|
Ok(Some(track_id))
|
||||||
}
|
}
|
||||||
MusicRenderer::Upnp(_)
|
MusicRendererBackend::Upnp(_)
|
||||||
| MusicRenderer::Chromecast(_)
|
| MusicRendererBackend::Chromecast(_)
|
||||||
| MusicRenderer::LinkPlay(_)
|
| MusicRendererBackend::LinkPlay(_)
|
||||||
| MusicRenderer::ArylicTcp(_)
|
| MusicRendererBackend::ArylicTcp(_)
|
||||||
| MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!(
|
| MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!(
|
||||||
"add_track_to_queue is not supported for {} backend",
|
"add_track_to_queue is not supported for {} backend",
|
||||||
self.unsupported_backend_name()
|
self.unsupported_backend_name()
|
||||||
)),
|
)),
|
||||||
@@ -392,12 +513,12 @@ impl MusicRenderer {
|
|||||||
/// - For others: returns error (use play_uri instead)
|
/// - For others: returns error (use play_uri instead)
|
||||||
pub fn select_queue_track(&self, track_id: u32) -> Result<()> {
|
pub fn select_queue_track(&self, track_id: u32) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(_) => self.openhome_playlist_play_id(track_id),
|
MusicRendererBackend::OpenHome(_) => self.openhome_playlist_play_id(track_id),
|
||||||
MusicRenderer::Upnp(_)
|
MusicRendererBackend::Upnp(_)
|
||||||
| MusicRenderer::Chromecast(_)
|
| MusicRendererBackend::Chromecast(_)
|
||||||
| MusicRenderer::LinkPlay(_)
|
| MusicRendererBackend::LinkPlay(_)
|
||||||
| MusicRenderer::ArylicTcp(_)
|
| MusicRendererBackend::ArylicTcp(_)
|
||||||
| MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!(
|
| MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!(
|
||||||
"select_queue_track is not supported for {} backend",
|
"select_queue_track is not supported for {} backend",
|
||||||
self.unsupported_backend_name()
|
self.unsupported_backend_name()
|
||||||
)),
|
)),
|
||||||
@@ -410,13 +531,15 @@ impl MusicRenderer {
|
|||||||
/// - For others: returns None (no persistent queue on backend)
|
/// - For others: returns None (no persistent queue on backend)
|
||||||
pub fn queue_snapshot(&self) -> Result<Option<QueueSnapshot>> {
|
pub fn queue_snapshot(&self) -> Result<Option<QueueSnapshot>> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(_) => {
|
MusicRendererBackend::OpenHome(_) => {
|
||||||
let oh_snapshot = self.fetch_openhome_playlist_snapshot()?;
|
let oh_snapshot = self.openhome_playlist_snapshot()?;
|
||||||
|
|
||||||
// Convert OpenHome tracks to PlaybackItems
|
// Convert OpenHome tracks to PlaybackItems
|
||||||
let items: Vec<PlaybackItem> = oh_snapshot.tracks.iter().map(|track| {
|
let items: Vec<PlaybackItem> = oh_snapshot
|
||||||
Self::playback_item_from_openhome_track(self.id(), track)
|
.tracks
|
||||||
}).collect();
|
.iter()
|
||||||
|
.map(|track| Self::playback_item_from_openhome_track(self.id(), track))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let snapshot = QueueSnapshot {
|
let snapshot = QueueSnapshot {
|
||||||
items,
|
items,
|
||||||
@@ -425,11 +548,11 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
Ok(Some(snapshot))
|
Ok(Some(snapshot))
|
||||||
}
|
}
|
||||||
MusicRenderer::Upnp(_)
|
MusicRendererBackend::Upnp(_)
|
||||||
| MusicRenderer::Chromecast(_)
|
| MusicRendererBackend::Chromecast(_)
|
||||||
| MusicRenderer::LinkPlay(_)
|
| MusicRendererBackend::LinkPlay(_)
|
||||||
| MusicRenderer::ArylicTcp(_)
|
| MusicRendererBackend::ArylicTcp(_)
|
||||||
| MusicRenderer::HybridUpnpArylic { .. } => {
|
| MusicRendererBackend::HybridUpnpArylic { .. } => {
|
||||||
// No backend queue for these renderers
|
// No backend queue for these renderers
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
@@ -442,9 +565,9 @@ impl MusicRenderer {
|
|||||||
/// - For others: Returns error (no backend queue)
|
/// - For others: Returns error (no backend queue)
|
||||||
pub fn play_current_from_backend_queue(&self) -> Result<()> {
|
pub fn play_current_from_backend_queue(&self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(_) => {
|
MusicRendererBackend::OpenHome(_) => {
|
||||||
// Get the current OpenHome playlist snapshot
|
// Get the current OpenHome playlist snapshot
|
||||||
let snapshot = self.fetch_openhome_playlist_snapshot()?;
|
let snapshot = self.openhome_playlist_snapshot()?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.id().0.as_str(),
|
renderer = self.id().0.as_str(),
|
||||||
@@ -488,25 +611,23 @@ impl MusicRenderer {
|
|||||||
if let Some(track_id) = target_track_id {
|
if let Some(track_id) = target_track_id {
|
||||||
info!(
|
info!(
|
||||||
renderer = self.id().0.as_str(),
|
renderer = self.id().0.as_str(),
|
||||||
track_id,
|
track_id, "Calling openhome_playlist_play_id to start playback"
|
||||||
"Calling openhome_playlist_play_id to start playback"
|
|
||||||
);
|
);
|
||||||
self.openhome_playlist_play_id(track_id)?;
|
self.openhome_playlist_play_id(track_id)?;
|
||||||
info!(
|
info!(
|
||||||
renderer = self.id().0.as_str(),
|
renderer = self.id().0.as_str(),
|
||||||
track_id,
|
track_id, "Successfully called openhome_playlist_play_id"
|
||||||
"Successfully called openhome_playlist_play_id"
|
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("No track to play in OpenHome playlist"))
|
Err(anyhow!("No track to play in OpenHome playlist"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MusicRenderer::Upnp(_)
|
MusicRendererBackend::Upnp(_)
|
||||||
| MusicRenderer::Chromecast(_)
|
| MusicRendererBackend::Chromecast(_)
|
||||||
| MusicRenderer::LinkPlay(_)
|
| MusicRendererBackend::LinkPlay(_)
|
||||||
| MusicRenderer::ArylicTcp(_)
|
| MusicRendererBackend::ArylicTcp(_)
|
||||||
| MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!(
|
| MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!(
|
||||||
"play_current_from_backend_queue is not supported for {} backend (no persistent queue)",
|
"play_current_from_backend_queue is not supported for {} backend (no persistent queue)",
|
||||||
self.unsupported_backend_name()
|
self.unsupported_backend_name()
|
||||||
)),
|
)),
|
||||||
@@ -519,9 +640,9 @@ impl MusicRenderer {
|
|||||||
/// - For others: Returns error (no backend queue)
|
/// - For others: Returns error (no backend queue)
|
||||||
pub fn play_next_from_backend_queue(&self) -> Result<()> {
|
pub fn play_next_from_backend_queue(&self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(_) => {
|
MusicRendererBackend::OpenHome(_) => {
|
||||||
// Get the current OpenHome playlist snapshot
|
// Get the current OpenHome playlist snapshot
|
||||||
let snapshot = self.fetch_openhome_playlist_snapshot()?;
|
let snapshot = self.openhome_playlist_snapshot()?;
|
||||||
|
|
||||||
if snapshot.tracks.is_empty() {
|
if snapshot.tracks.is_empty() {
|
||||||
return Err(anyhow!("OpenHome playlist is empty, cannot play next"));
|
return Err(anyhow!("OpenHome playlist is empty, cannot play next"));
|
||||||
@@ -544,14 +665,16 @@ impl MusicRenderer {
|
|||||||
self.openhome_playlist_play_id(track_id)?;
|
self.openhome_playlist_play_id(track_id)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("No track available to advance to in OpenHome playlist"))
|
Err(anyhow!(
|
||||||
|
"No track available to advance to in OpenHome playlist"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MusicRenderer::Upnp(_)
|
MusicRendererBackend::Upnp(_)
|
||||||
| MusicRenderer::Chromecast(_)
|
| MusicRendererBackend::Chromecast(_)
|
||||||
| MusicRenderer::LinkPlay(_)
|
| MusicRendererBackend::LinkPlay(_)
|
||||||
| MusicRenderer::ArylicTcp(_)
|
| MusicRendererBackend::ArylicTcp(_)
|
||||||
| MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!(
|
| MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!(
|
||||||
"play_next_from_backend_queue is not supported for {} backend (no persistent queue)",
|
"play_next_from_backend_queue is not supported for {} backend (no persistent queue)",
|
||||||
self.unsupported_backend_name()
|
self.unsupported_backend_name()
|
||||||
)),
|
)),
|
||||||
@@ -560,7 +683,7 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
/// Convert an OpenHome playlist track to a PlaybackItem.
|
/// Convert an OpenHome playlist track to a PlaybackItem.
|
||||||
fn playback_item_from_openhome_track(
|
fn playback_item_from_openhome_track(
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
track: &OpenHomePlaylistTrack,
|
track: &OpenHomePlaylistTrack,
|
||||||
) -> PlaybackItem {
|
) -> PlaybackItem {
|
||||||
let metadata = TrackMetadata {
|
let metadata = TrackMetadata {
|
||||||
@@ -638,12 +761,12 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
fn unsupported_backend_name(&self) -> &'static str {
|
fn unsupported_backend_name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::Upnp(_) => "UPnP",
|
MusicRendererBackend::Upnp(_) => "UPnP",
|
||||||
MusicRenderer::OpenHome(_) => "OpenHome",
|
MusicRendererBackend::OpenHome(_) => "OpenHome",
|
||||||
MusicRenderer::LinkPlay(_) => "LinkPlay",
|
MusicRendererBackend::LinkPlay(_) => "LinkPlay",
|
||||||
MusicRenderer::ArylicTcp(_) => "ArylicTcp",
|
MusicRendererBackend::ArylicTcp(_) => "ArylicTcp",
|
||||||
MusicRenderer::Chromecast(_) => "Chromecast",
|
MusicRendererBackend::Chromecast(_) => "Chromecast",
|
||||||
MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic",
|
MusicRendererBackend::HybridUpnpArylic { .. } => "HybridUpnpArylic",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,7 +778,7 @@ impl MusicRenderer {
|
|||||||
play: bool,
|
play: bool,
|
||||||
) -> Result<u32> {
|
) -> Result<u32> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(renderer) => {
|
MusicRendererBackend::OpenHome(renderer) => {
|
||||||
renderer.add_track_openhome(uri, metadata, after_id, play)
|
renderer.add_track_openhome(uri, metadata, after_id, play)
|
||||||
}
|
}
|
||||||
_ => Err(op_not_supported(
|
_ => Err(op_not_supported(
|
||||||
@@ -667,7 +790,7 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
fn fetch_openhome_playlist_play_id(&self, id: u32) -> Result<()> {
|
fn fetch_openhome_playlist_play_id(&self, id: u32) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::OpenHome(renderer) => renderer.play_openhome_track_id(id),
|
MusicRendererBackend::OpenHome(renderer) => renderer.play_openhome_track_id(id),
|
||||||
_ => Err(op_not_supported(
|
_ => Err(op_not_supported(
|
||||||
"openhome_playlist_play_id",
|
"openhome_playlist_play_id",
|
||||||
self.unsupported_backend_name(),
|
self.unsupported_backend_name(),
|
||||||
@@ -676,7 +799,7 @@ impl MusicRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn playback_item_from_params(
|
fn playback_item_from_params(
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
uri: &str,
|
uri: &str,
|
||||||
metadata_xml: &str,
|
metadata_xml: &str,
|
||||||
) -> Result<PlaybackItem> {
|
) -> Result<PlaybackItem> {
|
||||||
@@ -696,59 +819,61 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
/// Transport control façade that dispatches to whichever backend can fulfill
|
/// Transport control façade that dispatches to whichever backend can fulfill
|
||||||
/// the request, returning a standardized error if the backend lacks support.
|
/// the request, returning a standardized error if the backend lacks support.
|
||||||
impl TransportControl for MusicRenderer {
|
impl TransportControl for MusicRendererBackend {
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::Upnp(upnp) => upnp.play_uri(uri, meta),
|
MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta),
|
||||||
MusicRenderer::OpenHome(oh) => oh.play_uri(uri, meta),
|
MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta),
|
||||||
MusicRenderer::LinkPlay(lp) => lp.play_uri(uri, meta),
|
MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta),
|
||||||
MusicRenderer::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")),
|
MusicRendererBackend::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")),
|
||||||
MusicRenderer::Chromecast(cc) => cc.play_uri(uri, meta),
|
MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta),
|
||||||
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play(&self) -> Result<()> {
|
fn play(&self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::Upnp(upnp) => upnp.play(),
|
MusicRendererBackend::Upnp(upnp) => upnp.play(),
|
||||||
MusicRenderer::OpenHome(oh) => oh.play(),
|
MusicRendererBackend::OpenHome(oh) => oh.play(),
|
||||||
MusicRenderer::LinkPlay(lp) => lp.play(),
|
MusicRendererBackend::LinkPlay(lp) => lp.play(),
|
||||||
MusicRenderer::ArylicTcp(ary) => ary.play(),
|
MusicRendererBackend::ArylicTcp(ary) => ary.play(),
|
||||||
MusicRenderer::Chromecast(cc) => cc.play(),
|
MusicRendererBackend::Chromecast(cc) => cc.play(),
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pause(&self) -> Result<()> {
|
fn pause(&self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::Upnp(upnp) => upnp.pause(),
|
MusicRendererBackend::Upnp(upnp) => upnp.pause(),
|
||||||
MusicRenderer::OpenHome(oh) => oh.pause(),
|
MusicRendererBackend::OpenHome(oh) => oh.pause(),
|
||||||
MusicRenderer::LinkPlay(lp) => lp.pause(),
|
MusicRendererBackend::LinkPlay(lp) => lp.pause(),
|
||||||
MusicRenderer::ArylicTcp(ary) => ary.pause(),
|
MusicRendererBackend::ArylicTcp(ary) => ary.pause(),
|
||||||
MusicRenderer::Chromecast(cc) => cc.pause(),
|
MusicRendererBackend::Chromecast(cc) => cc.pause(),
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&self) -> Result<()> {
|
fn stop(&self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::Upnp(upnp) => upnp.stop(),
|
MusicRendererBackend::Upnp(upnp) => upnp.stop(),
|
||||||
MusicRenderer::OpenHome(oh) => oh.stop(),
|
MusicRendererBackend::OpenHome(oh) => oh.stop(),
|
||||||
MusicRenderer::LinkPlay(lp) => lp.stop(),
|
MusicRendererBackend::LinkPlay(lp) => lp.stop(),
|
||||||
MusicRenderer::ArylicTcp(ary) => ary.stop(),
|
MusicRendererBackend::ArylicTcp(ary) => ary.stop(),
|
||||||
MusicRenderer::Chromecast(cc) => cc.stop(),
|
MusicRendererBackend::Chromecast(cc) => cc.stop(),
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
|
MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
|
||||||
MusicRenderer::OpenHome(oh) => oh.seek_rel_time(hhmmss),
|
MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss),
|
||||||
MusicRenderer::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
|
MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
|
||||||
MusicRenderer::ArylicTcp(_) => Err(op_not_supported("seek_rel_time", "ArylicTcp")),
|
MusicRendererBackend::ArylicTcp(_) => {
|
||||||
MusicRenderer::Chromecast(cc) => cc.seek_rel_time(hhmmss),
|
Err(op_not_supported("seek_rel_time", "ArylicTcp"))
|
||||||
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
|
}
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -757,48 +882,48 @@ impl TransportControl for MusicRenderer {
|
|||||||
///
|
///
|
||||||
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
|
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
|
||||||
/// always depend on a single [`VolumeControl`] entry point.
|
/// always depend on a single [`VolumeControl`] entry point.
|
||||||
impl VolumeControl for MusicRenderer {
|
impl VolumeControl for MusicRendererBackend {
|
||||||
fn volume(&self) -> Result<u16> {
|
fn volume(&self) -> Result<u16> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.volume(),
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(),
|
||||||
MusicRenderer::ArylicTcp(ary) => ary.volume(),
|
MusicRendererBackend::ArylicTcp(ary) => ary.volume(),
|
||||||
MusicRenderer::OpenHome(oh) => oh.volume(),
|
MusicRendererBackend::OpenHome(oh) => oh.volume(),
|
||||||
MusicRenderer::Upnp(upnp) => upnp.volume(),
|
MusicRendererBackend::Upnp(upnp) => upnp.volume(),
|
||||||
MusicRenderer::LinkPlay(lp) => lp.volume(),
|
MusicRendererBackend::LinkPlay(lp) => lp.volume(),
|
||||||
MusicRenderer::Chromecast(cc) => cc.volume(),
|
MusicRendererBackend::Chromecast(cc) => cc.volume(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&self, vol: u16) -> Result<()> {
|
fn set_volume(&self, vol: u16) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
|
||||||
MusicRenderer::ArylicTcp(ary) => ary.set_volume(vol),
|
MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol),
|
||||||
MusicRenderer::OpenHome(oh) => oh.set_volume(vol),
|
MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol),
|
||||||
MusicRenderer::Upnp(upnp) => upnp.set_volume(vol),
|
MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol),
|
||||||
MusicRenderer::LinkPlay(lp) => lp.set_volume(vol),
|
MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol),
|
||||||
MusicRenderer::Chromecast(cc) => cc.set_volume(vol),
|
MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool> {
|
fn mute(&self) -> Result<bool> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.mute(),
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(),
|
||||||
MusicRenderer::OpenHome(r) => r.mute(),
|
MusicRendererBackend::OpenHome(r) => r.mute(),
|
||||||
MusicRenderer::Upnp(r) => r.get_master_mute(),
|
MusicRendererBackend::Upnp(r) => r.get_master_mute(),
|
||||||
MusicRenderer::LinkPlay(r) => r.mute(),
|
MusicRendererBackend::LinkPlay(r) => r.mute(),
|
||||||
MusicRenderer::ArylicTcp(r) => r.mute(),
|
MusicRendererBackend::ArylicTcp(r) => r.mute(),
|
||||||
MusicRenderer::Chromecast(cc) => cc.mute(),
|
MusicRendererBackend::Chromecast(cc) => cc.mute(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_mute(&self, m: bool) -> Result<()> {
|
fn set_mute(&self, m: bool) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
|
||||||
MusicRenderer::OpenHome(r) => r.set_mute(m),
|
MusicRendererBackend::OpenHome(r) => r.set_mute(m),
|
||||||
MusicRenderer::Upnp(r) => r.set_master_mute(m),
|
MusicRendererBackend::Upnp(r) => r.set_master_mute(m),
|
||||||
MusicRenderer::LinkPlay(r) => r.set_mute(m),
|
MusicRendererBackend::LinkPlay(r) => r.set_mute(m),
|
||||||
MusicRenderer::ArylicTcp(r) => r.set_mute(m),
|
MusicRendererBackend::ArylicTcp(r) => r.set_mute(m),
|
||||||
MusicRenderer::Chromecast(cc) => cc.set_mute(m),
|
MusicRendererBackend::Chromecast(cc) => cc.set_mute(m),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,30 +932,30 @@ impl VolumeControl for MusicRenderer {
|
|||||||
///
|
///
|
||||||
/// Each backend reports into [`PlaybackState`], ensuring consumers never have
|
/// Each backend reports into [`PlaybackState`], ensuring consumers never have
|
||||||
/// to reason about protocol-specific state machines.
|
/// to reason about protocol-specific state machines.
|
||||||
impl PlaybackStatus for MusicRenderer {
|
impl PlaybackStatus for MusicRendererBackend {
|
||||||
fn playback_state(&self) -> Result<PlaybackState> {
|
fn playback_state(&self) -> Result<PlaybackState> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r),
|
MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r),
|
||||||
MusicRenderer::OpenHome(r) => PlaybackStatus::playback_state(r),
|
MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r),
|
||||||
MusicRenderer::LinkPlay(r) => r.playback_state(),
|
MusicRendererBackend::LinkPlay(r) => r.playback_state(),
|
||||||
MusicRenderer::ArylicTcp(r) => r.playback_state(),
|
MusicRendererBackend::ArylicTcp(r) => r.playback_state(),
|
||||||
MusicRenderer::Chromecast(cc) => cc.playback_state(),
|
MusicRendererBackend::Chromecast(cc) => cc.playback_state(),
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playback-position queries that always yield a [`PlaybackPositionInfo`]
|
/// Playback-position queries that always yield a [`PlaybackPositionInfo`]
|
||||||
/// regardless of the backend providing the raw transport data.
|
/// regardless of the backend providing the raw transport data.
|
||||||
impl PlaybackPosition for MusicRenderer {
|
impl PlaybackPosition for MusicRendererBackend {
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
||||||
match self {
|
match self {
|
||||||
MusicRenderer::Upnp(r) => r.playback_position(),
|
MusicRendererBackend::Upnp(r) => r.playback_position(),
|
||||||
MusicRenderer::OpenHome(r) => r.playback_position(),
|
MusicRendererBackend::OpenHome(r) => r.playback_position(),
|
||||||
MusicRenderer::LinkPlay(r) => r.playback_position(),
|
MusicRendererBackend::LinkPlay(r) => r.playback_position(),
|
||||||
MusicRenderer::ArylicTcp(r) => r.playback_position(),
|
MusicRendererBackend::ArylicTcp(r) => r.playback_position(),
|
||||||
MusicRenderer::Chromecast(cc) => cc.playback_position(),
|
MusicRendererBackend::Chromecast(cc) => cc.playback_position(),
|
||||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
pmocontrol/src/online.rs
Normal file
49
pmocontrol/src/online.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use std::{
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{Instant, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone,Debug)]
|
||||||
|
pub struct DeviceConnectionState {
|
||||||
|
online: bool,
|
||||||
|
last_seen: Instant,
|
||||||
|
max_age: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait DeviceOnline {
|
||||||
|
fn is_online(&self) -> bool;
|
||||||
|
fn last_seen(&self) -> SystemTime;
|
||||||
|
fn has_been_seen_now(&self, max_age: u32);
|
||||||
|
fn mark_as_offline(&self);
|
||||||
|
fn max_age(&self) -> u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceConnectionState {
|
||||||
|
pub fn make() -> Arc<Mutex<Self>> {
|
||||||
|
Arc::new(Mutex::new(DeviceConnectionState {
|
||||||
|
online: false,
|
||||||
|
last_seen: std::time::UNIX_EPOCH,
|
||||||
|
max_age: 1800,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
pub fn is_online(&self) -> bool {
|
||||||
|
self.online
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_seen(&self) -> SystemTime {
|
||||||
|
self.last_seen
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_age(&self) -> u32 {
|
||||||
|
self.max_age
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_been_seen_now(&mut self, max_age: u32) {
|
||||||
|
self.last_seen = Instant::now();
|
||||||
|
self.max_age = max_age;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_as_offline(&mut self) {
|
||||||
|
self.online = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,46 +24,46 @@ impl OhServiceKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct OhServiceEndpoint<'a> {
|
pub struct OhServiceEndpoint {
|
||||||
pub control_url: &'a str,
|
pub control_url: String,
|
||||||
pub service_type: &'a str,
|
pub service_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn endpoint_for<'a>(info: &'a RendererInfo, kind: OhServiceKind) -> Option<OhServiceEndpoint<'a>> {
|
fn endpoint_for(info: &RendererInfo, kind: OhServiceKind) -> Option<OhServiceEndpoint> {
|
||||||
let (control_url, service_type) = match kind {
|
let (control_url, service_type) = match kind {
|
||||||
OhServiceKind::Playlist => (
|
OhServiceKind::Playlist => (
|
||||||
info.oh_playlist_control_url.as_deref()?,
|
info.oh_playlist_control_url()?,
|
||||||
info.oh_playlist_service_type.as_deref()?,
|
info.oh_playlist_service_type()?,
|
||||||
),
|
),
|
||||||
OhServiceKind::Info => (
|
OhServiceKind::Info => (
|
||||||
info.oh_info_control_url.as_deref()?,
|
info.oh_info_control_url()?,
|
||||||
info.oh_info_service_type.as_deref()?,
|
info.oh_info_service_type()?,
|
||||||
),
|
),
|
||||||
OhServiceKind::Time => (
|
OhServiceKind::Time => (
|
||||||
info.oh_time_control_url.as_deref()?,
|
info.oh_time_control_url()?,
|
||||||
info.oh_time_service_type.as_deref()?,
|
info.oh_time_service_type()?,
|
||||||
),
|
),
|
||||||
OhServiceKind::Volume => (
|
OhServiceKind::Volume => (
|
||||||
info.oh_volume_control_url.as_deref()?,
|
info.oh_volume_control_url()?,
|
||||||
info.oh_volume_service_type.as_deref()?,
|
info.oh_volume_service_type()?,
|
||||||
),
|
),
|
||||||
OhServiceKind::Product => (
|
OhServiceKind::Product => (
|
||||||
info.oh_product_control_url.as_deref()?,
|
info.oh_product_control_url()?,
|
||||||
info.oh_product_service_type.as_deref()?,
|
info.oh_product_service_type()?,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(OhServiceEndpoint {
|
Some(OhServiceEndpoint {
|
||||||
control_url,
|
control_url: control_url,
|
||||||
service_type,
|
service_type: service_type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn control_url_for<'a>(info: &'a RendererInfo, kind: OhServiceKind) -> Option<&'a str> {
|
pub fn control_url_for(info: &RendererInfo, kind: OhServiceKind) -> Option<String> {
|
||||||
endpoint_for(info, kind).map(|endpoint| endpoint.control_url)
|
endpoint_for(info, kind).map(|endpoint| endpoint.control_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn service_type_for<'a>(info: &'a RendererInfo, kind: OhServiceKind) -> Option<&'a str> {
|
pub fn service_type_for(info: &RendererInfo, kind: OhServiceKind) -> Option<String> {
|
||||||
endpoint_for(info, kind).map(|endpoint| endpoint.service_type)
|
endpoint_for(info, kind).map(|endpoint| endpoint.service_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,55 +108,52 @@ pub fn build_product_client(info: &RendererInfo) -> Option<OhProductClient> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_radio_client(info: &RendererInfo) -> Option<OhRadioClient> {
|
pub fn build_radio_client(info: &RendererInfo) -> Option<OhRadioClient> {
|
||||||
let control_url = info.oh_radio_control_url.as_ref()?;
|
let control_url = info.oh_radio_control_url()?;
|
||||||
let service_type = info.oh_radio_service_type.as_ref()?;
|
let service_type = info.oh_radio_service_type()?;
|
||||||
Some(OhRadioClient::new(
|
Some(OhRadioClient::new(
|
||||||
control_url.clone(),
|
control_url,
|
||||||
service_type.clone(),
|
service_type,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::{RendererCapabilities, RendererId, RendererInfo, RendererProtocol};
|
use crate::{DeviceId, model::{RendererCapabilities, RendererInfo, RendererProtocol}};
|
||||||
|
|
||||||
fn sample_renderer_info() -> RendererInfo {
|
fn sample_renderer_info() -> RendererInfo {
|
||||||
RendererInfo {
|
RendererInfo::make(
|
||||||
id: RendererId("renderer".into()),
|
DeviceId("renderer".into()),
|
||||||
udn: "renderer".into(),
|
"renderer".into(),
|
||||||
friendly_name: "Renderer".into(),
|
"Renderer".into(),
|
||||||
model_name: "Model".into(),
|
"Model".into(),
|
||||||
manufacturer: "Maker".into(),
|
"Maker".into(),
|
||||||
protocol: RendererProtocol::OpenHomeOnly,
|
RendererProtocol::OpenHomeOnly,
|
||||||
capabilities: RendererCapabilities::default(),
|
RendererCapabilities::default(),
|
||||||
location: "http://host:1234/description.xml".into(),
|
"http://host:1234/description.xml".into(),
|
||||||
server_header: "test".into(),
|
"test".into(),
|
||||||
online: true,
|
None,
|
||||||
last_seen: std::time::SystemTime::now(),
|
None,
|
||||||
max_age: 1800,
|
None,
|
||||||
avtransport_service_type: None,
|
None,
|
||||||
avtransport_control_url: None,
|
None,
|
||||||
rendering_control_service_type: None,
|
None,
|
||||||
rendering_control_control_url: None,
|
Some("urn:av-openhome-org:service:Playlist:1".into()),
|
||||||
connection_manager_service_type: None,
|
Some("http://host/oh/playlist".into()),
|
||||||
connection_manager_control_url: None,
|
Some("http://host/events/playlist".into()),
|
||||||
oh_playlist_service_type: Some("urn:av-openhome-org:service:Playlist:1".into()),
|
Some("urn:av-openhome-org:service:Info:1".into()),
|
||||||
oh_playlist_control_url: Some("http://host/oh/playlist".into()),
|
Some("http://host/oh/info".into()),
|
||||||
oh_playlist_event_sub_url: Some("http://host/events/playlist".into()),
|
Some("http://host/events/info".into()),
|
||||||
oh_info_service_type: Some("urn:av-openhome-org:service:Info:1".into()),
|
Some("urn:av-openhome-org:service:Time:1".into()),
|
||||||
oh_info_control_url: Some("http://host/oh/info".into()),
|
Some("http://host/oh/time".into()),
|
||||||
oh_info_event_sub_url: Some("http://host/events/info".into()),
|
Some("http://host/events/time".into()),
|
||||||
oh_time_service_type: Some("urn:av-openhome-org:service:Time:1".into()),
|
Some("urn:av-openhome-org:service:Volume:1".into()),
|
||||||
oh_time_control_url: Some("http://host/oh/time".into()),
|
Some("http://host/oh/volume".into()),
|
||||||
oh_time_event_sub_url: Some("http://host/events/time".into()),
|
None,
|
||||||
oh_volume_service_type: Some("urn:av-openhome-org:service:Volume:1".into()),
|
None,
|
||||||
oh_volume_control_url: Some("http://host/oh/volume".into()),
|
Some("urn:av-openhome-org:service:Product:1".into()),
|
||||||
oh_radio_service_type: None,
|
Some("http://host/oh/product".into()),
|
||||||
oh_radio_control_url: None,
|
)
|
||||||
oh_product_service_type: Some("urn:av-openhome-org:service:Product:1".into()),
|
|
||||||
oh_product_control_url: Some("http://host/oh/product".into()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -170,13 +167,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_none_when_service_missing() {
|
|
||||||
let mut info = sample_renderer_info();
|
|
||||||
info.oh_playlist_control_url = None;
|
|
||||||
assert!(endpoint_for(&info, OhServiceKind::Playlist).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn info_and_playlist_use_different_urls() {
|
fn info_and_playlist_use_different_urls() {
|
||||||
let info = sample_renderer_info();
|
let info = sample_renderer_info();
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
use crate::soap_client::{invoke_upnp_action, parse_upnp_error, SoapCallResult};
|
use crate::soap_client::{
|
||||||
use anyhow::{anyhow, Result};
|
decode_base64, ensure_success_with_envelope as ensure_success, extract_child_text,
|
||||||
use pmoupnp::soap::SoapEnvelope;
|
extract_child_text_any, extract_child_text_local, extract_child_text_optional,
|
||||||
use tracing::{debug, info, warn};
|
extract_child_text_optional_local, find_child_with_suffix, handle_action_response,
|
||||||
|
invoke_upnp_action, parse_bool, parse_visible_flag,
|
||||||
|
};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use tracing::{debug, info, trace, warn};
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
|
use crate::model::RendererInfo;
|
||||||
|
|
||||||
/// Value used by OpenHome renderers to indicate "insert at the head".
|
/// Value used by OpenHome renderers to indicate "insert at the head".
|
||||||
/// Several implementations, including upmpdcli, expect zero rather than the
|
/// Several implementations, including upmpdcli, expect zero rather than the
|
||||||
/// historical 0xFFFFFFFF sentinel.
|
/// historical 0xFFFFFFFF sentinel.
|
||||||
@@ -65,7 +72,16 @@ impl OhPlaylistClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_list(&self, id_list: &[u32]) -> Result<Vec<OhTrackEntry>> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
let control_url = info.oh_playlist_control_url()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist control client".to_string()))?;
|
||||||
|
|
||||||
|
let service_type = info.oh_playlist_service_type()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist service client".to_string()))?;
|
||||||
|
Ok(OhPlaylistClient::new(control_url, service_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_list(&self, id_list: &[u32]) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
||||||
if id_list.is_empty() {
|
if id_list.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -82,10 +98,9 @@ impl OhPlaylistClient {
|
|||||||
|
|
||||||
let envelope = ensure_success("ReadList", &call_result)?;
|
let envelope = ensure_success("ReadList", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing ReadListResponse element in SOAP body"))?;
|
.ok_or_else(|| ControlPointError::OpenHomeError(format!("Missing ReadListResponse element in SOAP body")))?;
|
||||||
|
|
||||||
let track_list_b64 =
|
let track_list_b64 = extract_child_text_any(response, &["TrackList", "Value"])?;
|
||||||
extract_child_text_any(response, &["TrackList", "Value"])?;
|
|
||||||
let track_list_sample: String = track_list_b64.chars().take(256).collect();
|
let track_list_sample: String = track_list_b64.chars().take(256).collect();
|
||||||
debug!(
|
debug!(
|
||||||
control_url = self.control_url.as_str(),
|
control_url = self.control_url.as_str(),
|
||||||
@@ -96,7 +111,12 @@ impl OhPlaylistClient {
|
|||||||
parse_track_list(&track_list_b64)
|
parse_track_list(&track_list_b64)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert(&self, after_id: u32, uri: &str, metadata: &str) -> Result<u32> {
|
pub fn insert(
|
||||||
|
&self,
|
||||||
|
after_id: u32,
|
||||||
|
uri: &str,
|
||||||
|
metadata: &str,
|
||||||
|
) -> Result<u32, ControlPointError> {
|
||||||
let after_id_str = after_id.to_string();
|
let after_id_str = after_id.to_string();
|
||||||
let args = [
|
let args = [
|
||||||
("AfterId", after_id_str.as_str()),
|
("AfterId", after_id_str.as_str()),
|
||||||
@@ -107,19 +127,23 @@ impl OhPlaylistClient {
|
|||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Insert", &args)?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "Insert", &args)?;
|
||||||
|
|
||||||
let envelope = ensure_success("Insert", &call_result)?;
|
let envelope = ensure_success("Insert", &call_result)
|
||||||
|
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "InsertResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "InsertResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing InsertResponse element in SOAP body"))?;
|
.ok_or_else(|| {
|
||||||
let new_id_text =
|
ControlPointError::UpnpMissingReturnValue("InsertResponse".to_string())
|
||||||
extract_child_text_any(response, &["NewId", "Value"])?;
|
})?;
|
||||||
|
|
||||||
|
let new_id_text = extract_child_text_any(response, &["NewId", "Value"])?;
|
||||||
let new_id = new_id_text
|
let new_id = new_id_text
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.map_err(|_| anyhow!("Invalid NewId value: {}", new_id_text))?;
|
.map_err(|_| ControlPointError::UpnpBadReturnValue("NewId".to_string(), new_id_text))?;
|
||||||
|
|
||||||
Ok(new_id)
|
Ok(new_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play_id(&self, id: u32) -> Result<()> {
|
pub fn play_id(&self, id: u32) -> Result<(), ControlPointError> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let args = [("Value", id_str.as_str())];
|
let args = [("Value", id_str.as_str())];
|
||||||
|
|
||||||
@@ -129,58 +153,63 @@ impl OhPlaylistClient {
|
|||||||
handle_action_response("SeekId", &call_result)
|
handle_action_response("SeekId", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transport_state(&self) -> Result<String> {
|
pub fn transport_state(&self) -> Result<String, ControlPointError> {
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "TransportState", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "TransportState", &[])?;
|
||||||
|
|
||||||
let envelope = ensure_success("TransportState", &call_result)?;
|
let envelope = ensure_success("TransportState", &call_result)?;
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "TransportStateResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "TransportStateResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?;
|
.ok_or_else(|| {
|
||||||
|
ControlPointError::UpnpMissingReturnValue("TransportStateResponse".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let state = extract_child_text_any(response, &["State", "Value"])?;
|
let state = extract_child_text_any(response, &["State", "Value"])?;
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> Result<u32> {
|
pub fn id(&self) -> Result<u32, ControlPointError> {
|
||||||
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 envelope = ensure_success("Id", &call_result)
|
||||||
|
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?;
|
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("IdResponse".to_string()))?;
|
||||||
let id_text = extract_child_text(response, "Value")?;
|
let id_text = extract_child_text(response, "Value")?;
|
||||||
let id = id_text
|
let id = id_text.parse::<u32>().map_err(|_| {
|
||||||
.parse::<u32>()
|
ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), id_text)
|
||||||
.map_err(|_| anyhow!("Invalid Playlist.Id value: {}", id_text))?;
|
})?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn play(&self) -> Result<(), ControlPointError> {
|
||||||
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)
|
handle_action_response("Play", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pause(&self) -> Result<()> {
|
pub fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
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)
|
handle_action_response("Pause", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> Result<()> {
|
pub fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
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)
|
handle_action_response("Stop", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next(&self) -> Result<()> {
|
pub fn next(&self) -> Result<(), ControlPointError> {
|
||||||
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)
|
handle_action_response("Next", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn previous(&self) -> Result<()> {
|
pub fn previous(&self) -> Result<(), ControlPointError> {
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Previous", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "Previous", &[])?;
|
||||||
handle_action_response("Previous", &call_result)
|
handle_action_response("Previous", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seek_second_absolute(&self, second: u32) -> Result<()> {
|
pub fn seek_second_absolute(&self, second: u32) -> Result<(), ControlPointError> {
|
||||||
let second_str = second.to_string();
|
let second_str = second.to_string();
|
||||||
let args = [("Second", second_str.as_str())];
|
let args = [("Second", second_str.as_str())];
|
||||||
let call_result = invoke_upnp_action(
|
let call_result = invoke_upnp_action(
|
||||||
@@ -193,7 +222,7 @@ impl OhPlaylistClient {
|
|||||||
handle_action_response("SeekSecondAbsolute", &call_result)
|
handle_action_response("SeekSecondAbsolute", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_id(&self, id: u32) -> Result<()> {
|
pub fn delete_id(&self, id: u32) -> Result<(), ControlPointError> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let args = [("Value", id_str.as_str())];
|
let args = [("Value", id_str.as_str())];
|
||||||
|
|
||||||
@@ -211,7 +240,7 @@ impl OhPlaylistClient {
|
|||||||
/// - Ok(true) if the ID was successfully deleted
|
/// - Ok(true) if the ID was successfully deleted
|
||||||
/// - Ok(false) if the ID didn't exist (logged as warning)
|
/// - Ok(false) if the ID didn't exist (logged as warning)
|
||||||
/// - Err(_) for other errors (network issues, etc.)
|
/// - Err(_) for other errors (network issues, etc.)
|
||||||
pub fn delete_id_if_exists(&self, id: u32) -> Result<bool> {
|
pub fn delete_id_if_exists(&self, id: u32) -> Result<bool, ControlPointError> {
|
||||||
match self.delete_id(id) {
|
match self.delete_id(id) {
|
||||||
Ok(()) => Ok(true),
|
Ok(()) => Ok(true),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -224,7 +253,8 @@ impl OhPlaylistClient {
|
|||||||
|| err_msg.contains("does not exist")
|
|| err_msg.contains("does not exist")
|
||||||
|| err_msg.contains("unknown")
|
|| err_msg.contains("unknown")
|
||||||
|| err_msg.contains("500") // HTTP 500 = renderer in inconsistent state
|
|| err_msg.contains("500") // HTTP 500 = renderer in inconsistent state
|
||||||
|| err_msg.contains("Action Failed") // UPnP error 501
|
|| err_msg.contains("Action Failed")
|
||||||
|
// UPnP error 501
|
||||||
{
|
{
|
||||||
warn!(
|
warn!(
|
||||||
control_url = self.control_url.as_str(),
|
control_url = self.control_url.as_str(),
|
||||||
@@ -240,15 +270,14 @@ impl OhPlaylistClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_all(&self) -> Result<()> {
|
pub fn delete_all(&self) -> Result<(), ControlPointError> {
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?;
|
||||||
handle_action_response("DeleteAll", &call_result)
|
handle_action_response("DeleteAll", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_id(&self) -> Result<String> {
|
pub fn current_id(&self) -> Result<String> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
|
|
||||||
let envelope = ensure_success("Id", &call_result)?;
|
let envelope = ensure_success("Id", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?;
|
.ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?;
|
||||||
@@ -256,33 +285,32 @@ impl OhPlaylistClient {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tracks_max(&self) -> Result<u32> {
|
pub fn tracks_max(&self) -> Result<u32, ControlPointError> {
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?;
|
||||||
|
|
||||||
let envelope = ensure_success("TracksMax", &call_result)?;
|
let envelope = ensure_success("TracksMax", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing TracksMaxResponse element in SOAP body"))?;
|
.ok_or_else(|| {
|
||||||
|
ControlPointError::UpnpMissingReturnValue("TracksMaxResponse".to_string())
|
||||||
|
})?;
|
||||||
let value_text: String = extract_child_text_any(response, &["Value"])?;
|
let value_text: String = extract_child_text_any(response, &["Value"])?;
|
||||||
let value = value_text
|
let value = value_text.parse::<u32>().map_err(|_| {
|
||||||
.parse::<u32>()
|
ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), value_text)
|
||||||
.map_err(|_| anyhow!("Invalid TracksMax value: {}", value_text))?;
|
})?;
|
||||||
|
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn id_array(&self) -> Result<Vec<u32>> {
|
pub fn id_array(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "IdArray", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "IdArray", &[])?;
|
||||||
let envelope = ensure_success("IdArray", &call_result)?;
|
let envelope = ensure_success("IdArray", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?;
|
.ok_or_else(|| ControlPointError::upnp_missing_return_value("IdArrayResponse"))?;
|
||||||
|
|
||||||
// Try to extract the array element. If missing, assume empty playlist.
|
// Try to extract the array element. If missing, assume empty playlist.
|
||||||
let array_text = match extract_child_text_any(
|
let array_text = match extract_child_text_any(response, &["Array", "IdArray", "Value"]) {
|
||||||
response,
|
|
||||||
&["Array", "IdArray", "Value"],
|
|
||||||
) {
|
|
||||||
Ok(text) => text,
|
Ok(text) => text,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Element not found - playlist is likely empty
|
// Element not found - playlist is likely empty
|
||||||
@@ -297,10 +325,10 @@ impl OhPlaylistClient {
|
|||||||
|
|
||||||
let bytes = decode_base64(&array_text)?;
|
let bytes = decode_base64(&array_text)?;
|
||||||
if bytes.len() % 4 != 0 {
|
if bytes.len() % 4 != 0 {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::SoapAction(format!(
|
||||||
"Invalid IdArray payload length {} (expected multiple of 4)",
|
"Invalid IdArray payload length {} (expected multiple of 4)",
|
||||||
bytes.len()
|
bytes.len()
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut ids = Vec::with_capacity(bytes.len() / 4);
|
let mut ids = Vec::with_capacity(bytes.len() / 4);
|
||||||
@@ -310,7 +338,7 @@ impl OhPlaylistClient {
|
|||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_all_tracks(&self) -> Result<Vec<OhTrackEntry>> {
|
pub fn read_all_tracks(&self) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
||||||
let ids = self.id_array()?;
|
let ids = self.id_array()?;
|
||||||
debug!(
|
debug!(
|
||||||
control_url = self.control_url.as_str(),
|
control_url = self.control_url.as_str(),
|
||||||
@@ -373,6 +401,15 @@ impl OhInfoClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
let control_url = info.oh_info_control_url()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info control client".to_string()))?;
|
||||||
|
|
||||||
|
let service_type = info.oh_info_service_type()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info service client".to_string()))?;
|
||||||
|
Ok(OhInfoClient::new(control_url, service_type))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn track(&self) -> Result<OhInfoTrack> {
|
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", &[])?;
|
||||||
|
|
||||||
@@ -380,8 +417,7 @@ impl OhInfoClient {
|
|||||||
let response = find_child_with_suffix(&envelope.body.content, "TrackResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "TrackResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing TrackResponse element in SOAP body"))?;
|
.ok_or_else(|| anyhow!("Missing TrackResponse element in SOAP body"))?;
|
||||||
|
|
||||||
let uri = extract_child_text_any(response, &["Uri", "Uri", "Value"])
|
let uri = extract_child_text_any(response, &["Uri", "Uri", "Value"]).unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
||||||
.unwrap_or(None)
|
.unwrap_or(None)
|
||||||
.filter(|s| !s.is_empty());
|
.filter(|s| !s.is_empty());
|
||||||
@@ -407,8 +443,7 @@ impl OhInfoClient {
|
|||||||
let response = find_child_with_suffix(&envelope.body.content, "NextResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "NextResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing NextResponse element in SOAP body"))?;
|
.ok_or_else(|| anyhow!("Missing NextResponse element in SOAP body"))?;
|
||||||
|
|
||||||
let uri = extract_child_text_any(response, &["Uri", "Value"])
|
let uri = extract_child_text_any(response, &["Uri", "Value"]).unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
||||||
.unwrap_or(None)
|
.unwrap_or(None)
|
||||||
.filter(|s| !s.is_empty());
|
.filter(|s| !s.is_empty());
|
||||||
@@ -436,25 +471,39 @@ impl OhTimeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn position(&self) -> Result<OhTimePosition> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
let control_url = info.oh_time_control_url()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time control client".to_string()))?;
|
||||||
|
|
||||||
|
let service_type = info.oh_time_service_type()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time service client".to_string()))?;
|
||||||
|
Ok(OhTimeClient::new(control_url, service_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn position(&self) -> Result<OhTimePosition, ControlPointError> {
|
||||||
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 envelope = ensure_success("Time", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "TimeResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "TimeResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing TimeResponse element in SOAP body"))?;
|
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("TimeResponse".to_string()))?;
|
||||||
|
|
||||||
let track_count =
|
let track_count = extract_child_text_any(response, &["TrackCount", "Value"])?
|
||||||
extract_child_text_any(response, &["TrackCount", "Value"])?
|
.parse::<u32>()
|
||||||
.parse::<u32>()
|
.map_err(|_| {
|
||||||
.map_err(|_| anyhow!("Invalid TrackCount value in Time response"))?;
|
ControlPointError::UpnpBadReturnValue("TrackCount".to_string(), "".to_string())
|
||||||
let duration_secs =
|
})?;
|
||||||
extract_child_text_any(response, &["Duration","Value"])?
|
|
||||||
.parse::<u32>()
|
let duration_secs = extract_child_text_any(response, &["Duration", "Value"])?
|
||||||
.map_err(|_| anyhow!("Invalid Duration value in Time response"))?;
|
.parse::<u32>()
|
||||||
let elapsed_secs =
|
.map_err(|_| {
|
||||||
extract_child_text_any(response, &["Seconds", "Value"])?
|
ControlPointError::UpnpBadReturnValue("Duration".to_string(), "".to_string())
|
||||||
.parse::<u32>()
|
})?;
|
||||||
.map_err(|_| anyhow!("Invalid Seconds value in Time response"))?;
|
|
||||||
|
let elapsed_secs = extract_child_text_any(response, &["Seconds", "Value"])?
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| {
|
||||||
|
ControlPointError::UpnpBadReturnValue("Second".to_string(), "".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(OhTimePosition {
|
Ok(OhTimePosition {
|
||||||
track_count,
|
track_count,
|
||||||
@@ -478,19 +527,33 @@ impl OhVolumeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn volume(&self) -> Result<u16> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
let control_url = info.oh_volume_control_url()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume control client".to_string()))?;
|
||||||
|
|
||||||
|
let service_type = info.oh_volume_service_type()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume service client".to_string()))?;
|
||||||
|
Ok(OhVolumeClient::new(control_url, service_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
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 envelope = ensure_success("Volume", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing VolumeResponse element in SOAP body"))?;
|
.ok_or_else(|| {
|
||||||
let value = extract_child_text_any(response, &["Volume", "Value"])?;
|
ControlPointError::UpnpMissingReturnValue("VolumeResponse".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let value = extract_child_text_any(response, &["Value"])?;
|
||||||
let parsed = value
|
let parsed = value
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.map_err(|_| anyhow!("Invalid volume value: {}", value))?;
|
.map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value))?;
|
||||||
|
|
||||||
Ok(parsed.min(u16::MAX as u32) as u16)
|
Ok(parsed.min(u16::MAX as u32) as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_volume(&self, vol: u16) -> Result<()> {
|
pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
||||||
let vol_str = vol.to_string();
|
let vol_str = vol.to_string();
|
||||||
let args = [("Value", vol_str.as_str())];
|
let args = [("Value", vol_str.as_str())];
|
||||||
let call_result =
|
let call_result =
|
||||||
@@ -498,18 +561,20 @@ impl OhVolumeClient {
|
|||||||
handle_action_response("SetVolume", &call_result)
|
handle_action_response("SetVolume", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mute(&self) -> Result<bool> {
|
pub fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
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 envelope = ensure_success("Mute", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "MuteResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "MuteResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing MuteResponse element in SOAP body"))?;
|
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("MuteResponse".to_string()))?;
|
||||||
let value = extract_child_text_any(response, &["Mute", "Value"])?;
|
|
||||||
parse_bool(&value)
|
let value = extract_child_text_any(response, &["Value"])?;
|
||||||
|
|
||||||
|
Ok(parse_bool(&value))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_mute(&self, mute: bool) -> Result<()> {
|
pub fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
|
||||||
let mute_str = if mute { "1" } else { "0" };
|
let mute_str = if mute { "1" } else { "0" };
|
||||||
let args = [("Mute", mute_str)];
|
let args = [("Value", mute_str)];
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
|
||||||
handle_action_response("SetMute", &call_result)
|
handle_action_response("SetMute", &call_result)
|
||||||
@@ -530,7 +595,16 @@ impl OhRadioClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play_channel(&self, id: u32) -> Result<()> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
let control_url = info.oh_radio_control_url()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio control client".to_string()))?;
|
||||||
|
|
||||||
|
let service_type = info.oh_radio_service_type()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio service client".to_string()))?;
|
||||||
|
Ok(OhRadioClient::new(control_url, service_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_channel(&self, id: u32) -> Result<(), ControlPointError> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let args = [("Id", id_str.as_str())];
|
let args = [("Id", id_str.as_str())];
|
||||||
let call_result =
|
let call_result =
|
||||||
@@ -548,8 +622,7 @@ impl OhRadioClient {
|
|||||||
let response = find_child_with_suffix(&envelope.body.content, "ChannelResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "ChannelResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing ChannelResponse element in SOAP body"))?;
|
.ok_or_else(|| anyhow!("Missing ChannelResponse element in SOAP body"))?;
|
||||||
|
|
||||||
let uri = extract_child_text_any(response, &["Uri", "Value"])
|
let uri = extract_child_text_any(response, &["Uri", "Value"]).unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
||||||
.unwrap_or(None)
|
.unwrap_or(None)
|
||||||
.filter(|s| !s.is_empty());
|
.filter(|s| !s.is_empty());
|
||||||
@@ -572,6 +645,16 @@ impl OhProductClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
let control_url = info.oh_product_control_url()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product control client".to_string()))?;
|
||||||
|
|
||||||
|
let service_type = info.oh_product_service_type()
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product service client".to_string()))?;
|
||||||
|
Ok(OhProductClient::new(control_url, service_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn source_xml(&self) -> Result<Vec<OhProductSource>> {
|
pub fn source_xml(&self) -> Result<Vec<OhProductSource>> {
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "SourceXml", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "SourceXml", &[])?;
|
||||||
@@ -582,19 +665,22 @@ impl OhProductClient {
|
|||||||
parse_product_source_list(&xml)
|
parse_product_source_list(&xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn source_index(&self) -> Result<u32> {
|
pub fn source_index(&self) -> Result<u32, ControlPointError> {
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "SourceIndex", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "SourceIndex", &[])?;
|
||||||
let envelope = ensure_success("SourceIndex", &call_result)?;
|
let envelope = ensure_success("SourceIndex", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "SourceIndexResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "SourceIndexResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing SourceIndexResponse element in SOAP body"))?;
|
.ok_or_else(|| {
|
||||||
|
ControlPointError::UpnpMissingReturnValue("SourceIndexResponse".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let value = extract_child_text_any(response, &["Index", "Value"])?;
|
let value = extract_child_text_any(response, &["Index", "Value"])?;
|
||||||
value
|
value
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.map_err(|_| anyhow!("Invalid Product.SourceIndex value: {}", value))
|
.map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_source_index(&self, index: u32) -> Result<()> {
|
pub fn set_source_index(&self, index: u32) -> Result<(), ControlPointError> {
|
||||||
let value = index.to_string();
|
let value = index.to_string();
|
||||||
let args = [("Index", value.as_str())];
|
let args = [("Index", value.as_str())];
|
||||||
let call_result = invoke_upnp_action(
|
let call_result = invoke_upnp_action(
|
||||||
@@ -606,8 +692,10 @@ impl OhProductClient {
|
|||||||
handle_action_response("SetSourceIndex", &call_result)
|
handle_action_response("SetSourceIndex", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_playlist_source_selected(&self) -> Result<()> {
|
pub fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
|
||||||
let sources = self.source_xml()?;
|
let sources = self
|
||||||
|
.source_xml()
|
||||||
|
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||||
|
|
||||||
// Log all available sources for diagnostics
|
// Log all available sources for diagnostics
|
||||||
debug!(
|
debug!(
|
||||||
@@ -615,8 +703,9 @@ impl OhProductClient {
|
|||||||
source_count = sources.len(),
|
source_count = sources.len(),
|
||||||
"OpenHome Product sources available"
|
"OpenHome Product sources available"
|
||||||
);
|
);
|
||||||
|
|
||||||
for (idx, source) in sources.iter().enumerate() {
|
for (idx, source) in sources.iter().enumerate() {
|
||||||
debug!(
|
trace!(
|
||||||
control_url = self.control_url.as_str(),
|
control_url = self.control_url.as_str(),
|
||||||
index = idx,
|
index = idx,
|
||||||
name = source.name.as_str(),
|
name = source.name.as_str(),
|
||||||
@@ -635,7 +724,7 @@ impl OhProductClient {
|
|||||||
available_types = ?sources.iter().map(|s| s.source_type.as_str()).collect::<Vec<_>>(),
|
available_types = ?sources.iter().map(|s| s.source_type.as_str()).collect::<Vec<_>>(),
|
||||||
"OpenHome Product source list does not expose a Playlist entry"
|
"OpenHome Product source list does not expose a Playlist entry"
|
||||||
);
|
);
|
||||||
anyhow!("OpenHome Product source list does not expose a Playlist entry")
|
ControlPointError::OpenHomeNoPlaylistEntry()
|
||||||
})?;
|
})?;
|
||||||
let playlist_index = playlist_index as u32;
|
let playlist_index = playlist_index as u32;
|
||||||
let current_index = self.source_index()?;
|
let current_index = self.source_index()?;
|
||||||
@@ -646,7 +735,9 @@ impl OhProductClient {
|
|||||||
control_url = self.control_url.as_str(),
|
control_url = self.control_url.as_str(),
|
||||||
current_index,
|
current_index,
|
||||||
current_source_name = current_source.map(|s| s.name.as_str()).unwrap_or("unknown"),
|
current_source_name = current_source.map(|s| s.name.as_str()).unwrap_or("unknown"),
|
||||||
current_source_type = current_source.map(|s| s.source_type.as_str()).unwrap_or("unknown"),
|
current_source_type = current_source
|
||||||
|
.map(|s| s.source_type.as_str())
|
||||||
|
.unwrap_or("unknown"),
|
||||||
playlist_index,
|
playlist_index,
|
||||||
needs_switch = current_index != playlist_index,
|
needs_switch = current_index != playlist_index,
|
||||||
"OpenHome source state"
|
"OpenHome source state"
|
||||||
@@ -708,7 +799,7 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>> {
|
fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
||||||
let trimmed = payload.trim();
|
let trimmed = payload.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -718,8 +809,9 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>> {
|
|||||||
(trimmed.to_string(), false)
|
(trimmed.to_string(), false)
|
||||||
} else {
|
} else {
|
||||||
let bytes = decode_base64(trimmed)?;
|
let bytes = decode_base64(trimmed)?;
|
||||||
let decoded = String::from_utf8(bytes)
|
let decoded = String::from_utf8(bytes).map_err(|err| {
|
||||||
.map_err(|err| anyhow!("TrackList payload not valid UTF-8 after base64 decode: {err}"))?;
|
ControlPointError::OpenHomeError(format!("TrackList payload not valid UTF-8 after base64 decode: {err}"))
|
||||||
|
})?;
|
||||||
(decoded, true)
|
(decoded, true)
|
||||||
};
|
};
|
||||||
let xml_sample: String = xml.chars().take(256).collect();
|
let xml_sample: String = xml.chars().take(256).collect();
|
||||||
@@ -732,7 +824,7 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>> {
|
|||||||
|
|
||||||
let mut reader = std::io::Cursor::new(xml.as_bytes());
|
let mut reader = std::io::Cursor::new(xml.as_bytes());
|
||||||
let root = Element::parse(&mut reader)
|
let root = Element::parse(&mut reader)
|
||||||
.map_err(|err| anyhow!("Failed to parse OpenHome TrackList XML: {}", err))?;
|
.map_err(|err| ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err)))?;
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
for node in &root.children {
|
for node in &root.children {
|
||||||
@@ -746,7 +838,7 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>> {
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry> {
|
fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry, ControlPointError> {
|
||||||
let id_text = extract_child_text_local(elem, "Id")?;
|
let id_text = extract_child_text_local(elem, "Id")?;
|
||||||
|
|
||||||
// Some renderers (like upmpdcli) return a comma-separated list of IDs in the Id element
|
// Some renderers (like upmpdcli) return a comma-separated list of IDs in the Id element
|
||||||
@@ -759,14 +851,14 @@ fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry> {
|
|||||||
raw_id = id_text.as_str(),
|
raw_id = id_text.as_str(),
|
||||||
"Multi-value Id element detected - forcing fallback to individual reads"
|
"Multi-value Id element detected - forcing fallback to individual reads"
|
||||||
);
|
);
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::OpenHomeError(format!(
|
||||||
"Renderer returned comma-separated IDs in single Entry - need individual reads"
|
"Renderer returned comma-separated IDs in single Entry - need individual reads"
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = id_text
|
let id = id_text
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.map_err(|_| anyhow!("Invalid OpenHome Entry Id: {}", id_text))?;
|
.map_err(|_| ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text)))?;
|
||||||
|
|
||||||
let uri = extract_child_text_local(elem, "Uri")?;
|
let uri = extract_child_text_local(elem, "Uri")?;
|
||||||
let metadata_xml = extract_child_text_optional_local(elem, "Metadata")?.unwrap_or_default();
|
let metadata_xml = extract_child_text_optional_local(elem, "Metadata")?.unwrap_or_default();
|
||||||
@@ -809,181 +901,7 @@ fn parse_product_source_list(xml: &str) -> Result<Vec<OhProductSource>> {
|
|||||||
Ok(sources)
|
Ok(sources)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_visible_flag(value: &str) -> bool {
|
fn is_invalid_entry_id_error(err: &ControlPointError) -> bool {
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.eq_ignore_ascii_case("true") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if trimmed.eq_ignore_ascii_case("false") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
trimmed == "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{action} failed with UPnP error {}: {} (HTTP status {})",
|
|
||||||
err.error_code,
|
|
||||||
err.error_description,
|
|
||||||
call_result.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{action} failed with HTTP status {} and body: {}",
|
|
||||||
call_result.status,
|
|
||||||
call_result.raw_body
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let envelope = call_result
|
|
||||||
.envelope
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in {action} response"))?;
|
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{action} returned UPnP error {}: {} (HTTP status {})",
|
|
||||||
err.error_code,
|
|
||||||
err.error_description,
|
|
||||||
call_result.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(envelope)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
|
||||||
ensure_success(action, call_result)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
|
|
||||||
parent.children.iter().find_map(|node| match node {
|
|
||||||
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_child_with_local_name<'a>(parent: &'a Element, local: &str) -> Option<&'a Element> {
|
|
||||||
parent.children.iter().find_map(|node| {
|
|
||||||
if let XMLNode::Element(elem) = node {
|
|
||||||
if elem.name == local || elem.name.ends_with(&format!(":{}", local)) {
|
|
||||||
return Some(elem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
|
|
||||||
let child = find_child_with_suffix(parent, suffix)
|
|
||||||
.ok_or_else(|| anyhow!("Missing {suffix} element in response"))?;
|
|
||||||
|
|
||||||
let text = child
|
|
||||||
.get_text()
|
|
||||||
.map(|t| t.trim().to_string())
|
|
||||||
.ok_or_else(|| anyhow!("{suffix} element missing text in response"))?;
|
|
||||||
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
Ok(Some(text))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_child_text_any(parent: &Element, suffixes: &[&str]) -> Result<String> {
|
|
||||||
for suffix in suffixes {
|
|
||||||
if let Ok(text) = extract_child_text(parent, suffix) {
|
|
||||||
return Ok(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(anyhow!(
|
|
||||||
"Missing {} element in response",
|
|
||||||
suffixes.join(" or ")
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_child_text_local(parent: &Element, local: &str) -> Result<String> {
|
|
||||||
let child = find_child_with_local_name(parent, local)
|
|
||||||
.ok_or_else(|| anyhow!("Missing {local} element in response"))?;
|
|
||||||
let text = child
|
|
||||||
.get_text()
|
|
||||||
.map(|t| t.trim().to_string())
|
|
||||||
.ok_or_else(|| anyhow!("{local} element missing text in response"))?;
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_child_text_optional_local(parent: &Element, local: &str) -> Result<Option<String>> {
|
|
||||||
if let Some(child) = find_child_with_local_name(parent, local) {
|
|
||||||
let text = child
|
|
||||||
.get_text()
|
|
||||||
.map(|t| t.trim().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
Ok(Some(text))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_bool(value: &str) -> Result<bool> {
|
|
||||||
match value.trim() {
|
|
||||||
"0" => Ok(false),
|
|
||||||
"1" => Ok(true),
|
|
||||||
other => Err(anyhow!("Invalid boolean value '{}'", other)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn decode_base64(input: &str) -> Result<Vec<u8>> {
|
|
||||||
fn value(byte: u8) -> Option<u8> {
|
|
||||||
match byte {
|
|
||||||
b'A'..=b'Z' => Some(byte - b'A'),
|
|
||||||
b'a'..=b'z' => Some(byte - b'a' + 26),
|
|
||||||
b'0'..=b'9' => Some(byte - b'0' + 52),
|
|
||||||
b'+' => Some(62),
|
|
||||||
b'/' => Some(63),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut output = Vec::new();
|
|
||||||
let mut buffer: u32 = 0;
|
|
||||||
let mut bits_collected: u8 = 0;
|
|
||||||
|
|
||||||
for byte in input.bytes() {
|
|
||||||
if byte == b'=' {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
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))?;
|
|
||||||
buffer = (buffer << 6) | (val as u32);
|
|
||||||
bits_collected += 6;
|
|
||||||
if bits_collected >= 8 {
|
|
||||||
bits_collected -= 8;
|
|
||||||
let out = (buffer >> bits_collected) & 0xFF;
|
|
||||||
output.push(out as u8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_invalid_entry_id_error(err: &anyhow::Error) -> bool {
|
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
msg.contains("Invalid OpenHome Entry Id") || msg.contains("comma-separated IDs")
|
msg.contains("Invalid OpenHome Entry Id") || msg.contains("comma-separated IDs")
|
||||||
}
|
}
|
||||||
@@ -998,8 +916,7 @@ mod tests {
|
|||||||
let xml = r#"<u:InsertResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><NewId>1337</NewId></u:InsertResponse>"#;
|
let xml = r#"<u:InsertResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><NewId>1337</NewId></u:InsertResponse>"#;
|
||||||
let mut cursor = Cursor::new(xml.as_bytes());
|
let mut cursor = Cursor::new(xml.as_bytes());
|
||||||
let response = Element::parse(&mut cursor).expect("valid xml");
|
let response = Element::parse(&mut cursor).expect("valid xml");
|
||||||
let value =
|
let value = extract_child_text_any(&response, &["NewId", "Value"]).unwrap();
|
||||||
extract_child_text_any(&response, &["NewId", "Value"]).unwrap();
|
|
||||||
assert_eq!(value, "1337");
|
assert_eq!(value, "1337");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,9 +925,7 @@ mod tests {
|
|||||||
let xml = r#"<u:ReadListResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><TrackList>PGVudHJ5PjwvZW50cnk+</TrackList></u:ReadListResponse>"#;
|
let xml = r#"<u:ReadListResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><TrackList>PGVudHJ5PjwvZW50cnk+</TrackList></u:ReadListResponse>"#;
|
||||||
let mut cursor = Cursor::new(xml.as_bytes());
|
let mut cursor = Cursor::new(xml.as_bytes());
|
||||||
let response = Element::parse(&mut cursor).expect("valid xml");
|
let response = Element::parse(&mut cursor).expect("valid xml");
|
||||||
let value =
|
let value = extract_child_text_any(&response, &["TrackList", "Value"]).expect("tracklist");
|
||||||
extract_child_text_any(&response, &["TrackList", "Value"])
|
|
||||||
.expect("tracklist");
|
|
||||||
assert_eq!(value, "PGVudHJ5PjwvZW50cnk+");
|
assert_eq!(value, "PGVudHJ5PjwvZW50cnk+");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1019,11 +934,8 @@ mod tests {
|
|||||||
let xml = r#"<u:IdArrayResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><Token>1</Token><Array>AAAAAQAAAAI=</Array></u:IdArrayResponse>"#;
|
let xml = r#"<u:IdArrayResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><Token>1</Token><Array>AAAAAQAAAAI=</Array></u:IdArrayResponse>"#;
|
||||||
let mut cursor = Cursor::new(xml.as_bytes());
|
let mut cursor = Cursor::new(xml.as_bytes());
|
||||||
let response = Element::parse(&mut cursor).expect("valid xml");
|
let response = Element::parse(&mut cursor).expect("valid xml");
|
||||||
let value = extract_child_text_any(
|
let value = extract_child_text_any(&response, &["Array", "IdArray", "Value"])
|
||||||
&response,
|
.expect("array content");
|
||||||
&["Array", "IdArray", "Value"],
|
|
||||||
)
|
|
||||||
.expect("array content");
|
|
||||||
assert_eq!(value, "AAAAAQAAAAI=");
|
assert_eq!(value, "AAAAAQAAAAI=");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::{DeviceIdentity, MusicRendererBackend};
|
||||||
use crate::capabilities::{
|
use crate::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||||
VolumeControl,
|
VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
|
||||||
use crate::music_renderer::op_not_supported;
|
use crate::errors::ControlPointError;
|
||||||
|
use crate::model::RendererInfo;
|
||||||
use crate::openhome::{
|
use crate::openhome::{
|
||||||
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
||||||
build_time_client, build_volume_client,
|
build_time_client, build_volume_client,
|
||||||
};
|
};
|
||||||
use crate::openhome_client::{
|
use crate::openhome_client::{
|
||||||
parse_track_metadata_from_didl, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
||||||
OhTimeClient, OhTrackEntry, OhVolumeClient, OPENHOME_PLAYLIST_HEAD_ID,
|
OhTimeClient, OhTrackEntry, OhVolumeClient, parse_track_metadata_from_didl,
|
||||||
};
|
};
|
||||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{Result, anyhow};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct OpenHomeRenderer {
|
pub struct OpenHomeRenderer {
|
||||||
pub info: RendererInfo,
|
|
||||||
playlist: Option<OhPlaylistClient>,
|
playlist: Option<OhPlaylistClient>,
|
||||||
info_client: Option<OhInfoClient>,
|
info_client: Option<OhInfoClient>,
|
||||||
time_client: Option<OhTimeClient>,
|
time_client: Option<OhTimeClient>,
|
||||||
@@ -29,28 +32,46 @@ pub struct OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OpenHomeRenderer {
|
impl OpenHomeRenderer {
|
||||||
pub fn new(info: RendererInfo) -> Self {
|
pub fn new(
|
||||||
|
playlist: Option<OhPlaylistClient>,
|
||||||
|
info_client: Option<OhInfoClient>,
|
||||||
|
time_client: Option<OhTimeClient>,
|
||||||
|
volume_client: Option<OhVolumeClient>,
|
||||||
|
product_client: Option<OhProductClient>,
|
||||||
|
radio_client: Option<OhRadioClient>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
playlist: build_playlist_client(&info),
|
playlist,
|
||||||
info_client: build_info_client(&info),
|
info_client,
|
||||||
time_client: build_time_client(&info),
|
time_client,
|
||||||
volume_client: build_volume_client(&info),
|
volume_client,
|
||||||
product_client: build_product_client(&info),
|
product_client,
|
||||||
radio_client: build_radio_client(&info),
|
radio_client,
|
||||||
info,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> &RendererId {
|
pub fn from_renderer_info(
|
||||||
&self.info.id
|
info: RendererInfo,
|
||||||
}
|
) -> Result<Arc<Mutex<MusicRendererBackend>>, ControlPointError> {
|
||||||
|
let renderer = OpenHomeRenderer::new(
|
||||||
|
build_playlist_client(&info),
|
||||||
|
build_info_client(&info),
|
||||||
|
build_time_client(&info),
|
||||||
|
build_volume_client(&info),
|
||||||
|
build_product_client(&info),
|
||||||
|
build_radio_client(&info),
|
||||||
|
);
|
||||||
|
|
||||||
pub fn friendly_name(&self) -> &str {
|
if renderer.has_any_openhome_service() {
|
||||||
&self.info.friendly_name
|
Ok(Arc::new(Mutex::new(MusicRendererBackend::OpenHome(
|
||||||
}
|
renderer,
|
||||||
|
))))
|
||||||
pub fn protocol(&self) -> &RendererProtocol {
|
} else {
|
||||||
&self.info.protocol
|
Err(ControlPointError::OpenHomeNotAValidDevice(format!(
|
||||||
|
"{:?}",
|
||||||
|
info.id()
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_playlist(&self) -> bool {
|
pub fn has_playlist(&self) -> bool {
|
||||||
@@ -73,74 +94,68 @@ impl OpenHomeRenderer {
|
|||||||
self.has_playlist() || self.has_info() || self.has_time() || self.has_volume()
|
self.has_playlist() || self.has_info() || self.has_time() || self.has_volume()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient> {
|
fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient, ControlPointError> {
|
||||||
let playlist = self
|
let playlist = self
|
||||||
.playlist
|
.playlist
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| op_not_supported(op, "OpenHome Playlist"))?;
|
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist"))?;
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
Ok(playlist)
|
Ok(playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn info_client_for(&self, op: &str) -> Result<&OhInfoClient> {
|
fn info_client_for(&self, op: &str) -> Result<&OhInfoClient, ControlPointError> {
|
||||||
self.info_client
|
self.info_client
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| op_not_supported(op, "OpenHome Info"))
|
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Info"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn time_client_for(&self, op: &str) -> Result<&OhTimeClient> {
|
fn time_client_for(&self, op: &str) -> Result<&OhTimeClient, ControlPointError> {
|
||||||
self.time_client
|
self.time_client
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| op_not_supported(op, "OpenHome Time"))
|
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Time"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn volume_client_for(&self, op: &str) -> Result<&OhVolumeClient> {
|
fn volume_client_for(&self, op: &str) -> Result<&OhVolumeClient, ControlPointError> {
|
||||||
self.volume_client
|
self.volume_client
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| op_not_supported(op, "OpenHome Volume"))
|
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Volume"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_playlist_source_selected(&self) -> Result<()> {
|
fn ensure_playlist_source_selected(&self) -> Result<(),ControlPointError> {
|
||||||
if let Some(product) = &self.product_client {
|
if let Some(product) = &self.product_client {
|
||||||
product.ensure_playlist_source_selected().map_err(|err| {
|
product
|
||||||
anyhow!(
|
.ensure_playlist_source_selected()
|
||||||
"Failed to select OpenHome Playlist source for {}: {}",
|
|
||||||
self.info.id.0,
|
|
||||||
err
|
|
||||||
)
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn snapshot_openhome_playlist(&self) -> Result<OpenHomePlaylistSnapshot> {
|
// pub(crate) fn snapshot_openhome_playlist(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||||||
let playlist = self.playlist_client_for("snapshot_openhome_playlist")?;
|
// let playlist = self.playlist_client_for("snapshot_openhome_playlist")?;
|
||||||
let entries = playlist.read_all_tracks()?;
|
// let entries = playlist.read_all_tracks()?;
|
||||||
|
|
||||||
// Get current track ID from the playlist service
|
// // Get current track ID from the playlist service
|
||||||
let current_id = playlist.id().ok();
|
// let current_id = playlist.id().ok();
|
||||||
|
|
||||||
let current_index =
|
// let current_index =
|
||||||
current_id.and_then(|id| entries.iter().position(|entry| entry.id == id));
|
// current_id.and_then(|id| entries.iter().position(|entry| entry.id == id));
|
||||||
|
|
||||||
debug!(
|
// debug!(
|
||||||
renderer = self.info.id.0.as_str(),
|
// current_id = ?current_id,
|
||||||
current_id = ?current_id,
|
// current_index = ?current_index,
|
||||||
current_index = ?current_index,
|
// track_count = entries.len(),
|
||||||
track_count = entries.len(),
|
// "snapshot_openhome_playlist completed"
|
||||||
"snapshot_openhome_playlist completed"
|
// );
|
||||||
);
|
|
||||||
|
|
||||||
let tracks = entries.iter().map(convert_oh_track_entry).collect();
|
// let tracks = entries.iter().map(convert_oh_track_entry).collect();
|
||||||
|
|
||||||
|
// Ok(OpenHomePlaylistSnapshot {
|
||||||
|
// current_id,
|
||||||
|
// current_index,
|
||||||
|
// tracks,
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
Ok(OpenHomePlaylistSnapshot {
|
|
||||||
renderer_id: self.info.id.0.clone(),
|
|
||||||
current_id,
|
|
||||||
current_index,
|
|
||||||
tracks,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
|
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
|
||||||
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
|
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
|
||||||
@@ -152,12 +167,12 @@ impl OpenHomeRenderer {
|
|||||||
|
|
||||||
/// Retourne les IDs des pistes de la playlist OpenHome.
|
/// Retourne les IDs des pistes de la playlist OpenHome.
|
||||||
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
|
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
|
||||||
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>> {
|
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("openhome_playlist_ids")?;
|
let playlist = self.playlist_client_for("openhome_playlist_ids")?;
|
||||||
playlist.id_array()
|
playlist.id_array()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn clear_openhome_playlist(&self) -> Result<()> {
|
pub(crate) fn clear_openhome_playlist(&self) -> Result<(), ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("clear_openhome_playlist")?;
|
let playlist = self.playlist_client_for("clear_openhome_playlist")?;
|
||||||
playlist.delete_all()
|
playlist.delete_all()
|
||||||
}
|
}
|
||||||
@@ -168,7 +183,7 @@ impl OpenHomeRenderer {
|
|||||||
metadata: &str,
|
metadata: &str,
|
||||||
after_id: Option<u32>,
|
after_id: Option<u32>,
|
||||||
play: bool,
|
play: bool,
|
||||||
) -> Result<u32> {
|
) -> Result<u32, ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("add_track_openhome")?;
|
let playlist = self.playlist_client_for("add_track_openhome")?;
|
||||||
let insert_after = match after_id {
|
let insert_after = match after_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
@@ -186,19 +201,19 @@ impl OpenHomeRenderer {
|
|||||||
Ok(new_id)
|
Ok(new_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<()> {
|
pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<(), ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("play_openhome_track_id")?;
|
let playlist = self.playlist_client_for("play_openhome_track_id")?;
|
||||||
playlist.play_id(id)
|
playlist.play_id(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportControl for OpenHomeRenderer {
|
impl TransportControl for OpenHomeRenderer {
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("play_uri")?;
|
let playlist = self.playlist_client_for("play_uri")?;
|
||||||
|
|
||||||
if let Err(err) = playlist.delete_all() {
|
if let Err(err) = playlist.delete_all() {
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.info.id.0.as_str(),
|
// renderer = self.info.id.0.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
"Failed to clear OpenHome playlist before insert"
|
"Failed to clear OpenHome playlist before insert"
|
||||||
);
|
);
|
||||||
@@ -210,27 +225,24 @@ impl TransportControl for OpenHomeRenderer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play(&self) -> Result<()> {
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("play")?;
|
let playlist = self.playlist_client_for("play")?;
|
||||||
playlist.play()
|
playlist.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pause(&self) -> Result<()> {
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("pause")?;
|
let playlist = self.playlist_client_for("pause")?;
|
||||||
playlist.pause()
|
playlist.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&self) -> Result<()> {
|
fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("stop")?;
|
let playlist = self.playlist_client_for("stop")?;
|
||||||
playlist.stop()
|
playlist.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
let seconds = parse_hms(hhmmss).ok_or_else(|| {
|
let seconds = parse_hms(hhmmss).ok_or_else(|| {
|
||||||
anyhow!(
|
ControlPointError::upnp_bad_return_value("HH:MM:SS", &hhmmss)
|
||||||
"Invalid HH:MM:SS format for OpenHome SeekSecondAbsolute: {}",
|
|
||||||
hhmmss
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
let playlist = self.playlist_client_for("seek_rel_time")?;
|
let playlist = self.playlist_client_for("seek_rel_time")?;
|
||||||
playlist.seek_second_absolute(seconds)
|
playlist.seek_second_absolute(seconds)
|
||||||
@@ -238,29 +250,29 @@ impl TransportControl for OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VolumeControl for OpenHomeRenderer {
|
impl VolumeControl for OpenHomeRenderer {
|
||||||
fn volume(&self) -> Result<u16> {
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
let client = self.volume_client_for("volume")?;
|
let client = self.volume_client_for("volume")?;
|
||||||
client.volume()
|
client.volume()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&self, v: u16) -> Result<()> {
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||||
let client = self.volume_client_for("set_volume")?;
|
let client = self.volume_client_for("set_volume")?;
|
||||||
client.set_volume(v)
|
client.set_volume(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool> {
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
let client = self.volume_client_for("mute")?;
|
let client = self.volume_client_for("mute")?;
|
||||||
client.mute()
|
client.mute()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_mute(&self, m: bool) -> Result<()> {
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||||
let client = self.volume_client_for("set_mute")?;
|
let client = self.volume_client_for("set_mute")?;
|
||||||
client.set_mute(m)
|
client.set_mute(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackStatus for OpenHomeRenderer {
|
impl PlaybackStatus for OpenHomeRenderer {
|
||||||
fn playback_state(&self) -> Result<PlaybackState> {
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
let client = self.playlist_client_for("playback_state")?;
|
let client = self.playlist_client_for("playback_state")?;
|
||||||
let state = client.transport_state()?;
|
let state = client.transport_state()?;
|
||||||
Ok(map_openhome_state(&state))
|
Ok(map_openhome_state(&state))
|
||||||
@@ -268,7 +280,7 @@ impl PlaybackStatus for OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackPosition for OpenHomeRenderer {
|
impl PlaybackPosition for OpenHomeRenderer {
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
let time_info = self.time_client_for("playback_position")?.position()?;
|
let time_info = self.time_client_for("playback_position")?.position()?;
|
||||||
|
|
||||||
let mut track_id = None;
|
let mut track_id = None;
|
||||||
@@ -279,7 +291,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
|||||||
match playlist_client.id() {
|
match playlist_client.id() {
|
||||||
Ok(id) => track_id = Some(id),
|
Ok(id) => track_id = Some(id),
|
||||||
Err(err) => debug!(
|
Err(err) => debug!(
|
||||||
renderer = self.info.id.0.as_str(),
|
// renderer = self.info.id.0.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
"Failed to read OpenHome track id"
|
"Failed to read OpenHome track id"
|
||||||
),
|
),
|
||||||
@@ -293,7 +305,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
|||||||
track_metadata_xml = track.metadata_xml;
|
track_metadata_xml = track.metadata_xml;
|
||||||
}
|
}
|
||||||
Err(err) => debug!(
|
Err(err) => debug!(
|
||||||
renderer = self.info.id.0.as_str(),
|
// renderer = self.info.id.0.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
"Failed to read OpenHome track metadata"
|
"Failed to read OpenHome track metadata"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ use crate::control_point::{
|
|||||||
};
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::media_server::{
|
use crate::media_server::{
|
||||||
playback_item_from_entry, MediaBrowser, MediaEntry, MusicServer, ServerId,
|
playback_item_from_entry, MediaBrowser, MediaEntry, UpnpMediaServer, ServerId,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::model::{RendererCapabilities, RendererId, RendererProtocol, TrackMetadata};
|
use crate::model::{RendererCapabilities, ServiceId, RendererProtocol, TrackMetadata};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::openapi::{
|
use crate::openapi::{
|
||||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||||
@@ -136,7 +136,7 @@ async fn get_renderer_state(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let snapshot = state
|
let snapshot = state
|
||||||
.control_point
|
.control_point
|
||||||
.renderer_full_snapshot(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
@@ -162,7 +162,7 @@ async fn get_renderer_full_snapshot(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let snapshot = state
|
let snapshot = state
|
||||||
.control_point
|
.control_point
|
||||||
.renderer_full_snapshot(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
@@ -193,7 +193,7 @@ async fn get_renderer_queue(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let snapshot = state
|
let snapshot = state
|
||||||
.control_point
|
.control_point
|
||||||
.renderer_full_snapshot(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
@@ -220,7 +220,7 @@ async fn get_renderer_binding(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let snapshot = state
|
let snapshot = state
|
||||||
.control_point
|
.control_point
|
||||||
.renderer_full_snapshot(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
@@ -252,7 +252,7 @@ async fn play_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -328,7 +328,7 @@ async fn pause_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -404,7 +404,7 @@ async fn stop_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
state
|
state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -481,7 +481,7 @@ async fn resume_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
state
|
state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -562,7 +562,7 @@ async fn next_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
state
|
state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -643,7 +643,7 @@ async fn seek_queue_index(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(payload): Json<SeekQueueRequest>,
|
Json(payload): Json<SeekQueueRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
state
|
state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -729,7 +729,7 @@ async fn set_renderer_volume(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(req): Json<VolumeSetRequest>,
|
Json(req): Json<VolumeSetRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
@@ -807,7 +807,7 @@ async fn volume_up_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
@@ -892,7 +892,7 @@ async fn volume_down_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
@@ -977,7 +977,7 @@ async fn toggle_mute_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
@@ -1064,7 +1064,7 @@ async fn attach_playlist_binding(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(req): Json<AttachPlaylistRequest>,
|
Json(req): Json<AttachPlaylistRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let sid = ServerId(req.server_id.clone());
|
let sid = ServerId(req.server_id.clone());
|
||||||
let container_id = req.container_id.clone();
|
let container_id = req.container_id.clone();
|
||||||
let control_point = Arc::clone(&state.control_point);
|
let control_point = Arc::clone(&state.control_point);
|
||||||
@@ -1146,7 +1146,7 @@ async fn detach_playlist_binding(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Json<SuccessResponse> {
|
) -> Json<SuccessResponse> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
|
|
||||||
state.control_point.detach_queue_playlist(&rid);
|
state.control_point.detach_queue_playlist(&rid);
|
||||||
|
|
||||||
@@ -1178,47 +1178,47 @@ async fn detach_playlist_binding(
|
|||||||
),
|
),
|
||||||
tag = "control"
|
tag = "control"
|
||||||
)]
|
)]
|
||||||
async fn get_openhome_playlist(
|
// async fn get_openhome_playlist(
|
||||||
State(state): State<ControlPointState>,
|
// State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
// Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<OpenHomePlaylistSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
// ) -> Result<Json<OpenHomePlaylistSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
// let rid = RendererId(renderer_id.clone());
|
||||||
let control_point = Arc::clone(&state.control_point);
|
// let control_point = Arc::clone(&state.control_point);
|
||||||
let rid_for_task = rid.clone();
|
// let rid_for_task = rid.clone();
|
||||||
|
|
||||||
let fetch_task = tokio::task::spawn_blocking(move || {
|
// let fetch_task = tokio::task::spawn_blocking(move || {
|
||||||
control_point.get_cached_openhome_playlist_snapshot(
|
// control_point.get_cached_openhome_playlist_snapshot(
|
||||||
&rid_for_task,
|
// &rid_for_task,
|
||||||
OPENHOME_SNAPSHOT_CACHE_TTL,
|
// OPENHOME_SNAPSHOT_CACHE_TTL,
|
||||||
)
|
// )
|
||||||
});
|
// });
|
||||||
|
|
||||||
let snapshot = fetch_task
|
// let snapshot = fetch_task
|
||||||
.await
|
// .await
|
||||||
.map_err(|e| {
|
// .map_err(|e| {
|
||||||
warn!(
|
// warn!(
|
||||||
renderer = renderer_id.as_str(),
|
// renderer = renderer_id.as_str(),
|
||||||
error = %e,
|
// error = %e,
|
||||||
"Join error while fetching OpenHome playlist"
|
// "Join error while fetching OpenHome playlist"
|
||||||
);
|
// );
|
||||||
(
|
// (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
// StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(ErrorResponse {
|
// Json(ErrorResponse {
|
||||||
error: format!("Internal task error: {}", e),
|
// error: format!("Internal task error: {}", e),
|
||||||
}),
|
// }),
|
||||||
)
|
// )
|
||||||
})?
|
// })?
|
||||||
.map_err(|e| {
|
// .map_err(|e| {
|
||||||
warn!(
|
// warn!(
|
||||||
renderer = renderer_id.as_str(),
|
// renderer = renderer_id.as_str(),
|
||||||
error = %e,
|
// error = %e,
|
||||||
"Failed to read OpenHome playlist"
|
// "Failed to read OpenHome playlist"
|
||||||
);
|
// );
|
||||||
map_openhome_error(&rid, e, "read OpenHome playlist")
|
// map_openhome_error(&rid, e, "read OpenHome playlist")
|
||||||
})?;
|
// })?;
|
||||||
|
|
||||||
Ok(Json(snapshot))
|
// Ok(Json(snapshot))
|
||||||
}
|
// }
|
||||||
|
|
||||||
/// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH
|
/// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
@@ -1238,7 +1238,7 @@ async fn clear_openhome_playlist(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let control_point = Arc::clone(&state.control_point);
|
let control_point = Arc::clone(&state.control_point);
|
||||||
let rid_for_task = rid.clone();
|
let rid_for_task = rid.clone();
|
||||||
|
|
||||||
@@ -1310,7 +1310,7 @@ async fn add_openhome_playlist_item(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(req): Json<OpenHomePlaylistAddRequest>,
|
Json(req): Json<OpenHomePlaylistAddRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let control_point = Arc::clone(&state.control_point);
|
let control_point = Arc::clone(&state.control_point);
|
||||||
let rid_for_task = rid.clone();
|
let rid_for_task = rid.clone();
|
||||||
|
|
||||||
@@ -1389,7 +1389,7 @@ async fn play_openhome_track(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path((renderer_id, track_id)): Path<(String, String)>,
|
Path((renderer_id, track_id)): Path<(String, String)>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let parsed_id = track_id.parse::<u32>().map_err(|e| {
|
let parsed_id = track_id.parse::<u32>().map_err(|e| {
|
||||||
(
|
(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
@@ -1477,7 +1477,7 @@ async fn play_content(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(req): Json<PlayContentRequest>,
|
Json(req): Json<PlayContentRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let sid = ServerId(req.server_id.clone());
|
let sid = ServerId(req.server_id.clone());
|
||||||
let object_id = req.object_id.clone();
|
let object_id = req.object_id.clone();
|
||||||
let object_id_for_log = object_id.clone();
|
let object_id_for_log = object_id.clone();
|
||||||
@@ -1606,7 +1606,7 @@ async fn add_to_queue(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(req): Json<PlayContentRequest>,
|
Json(req): Json<PlayContentRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = ServiceId(renderer_id.clone());
|
||||||
let sid = ServerId(req.server_id.clone());
|
let sid = ServerId(req.server_id.clone());
|
||||||
let object_id = req.object_id.clone();
|
let object_id = req.object_id.clone();
|
||||||
let object_id_for_log = object_id.clone();
|
let object_id_for_log = object_id.clone();
|
||||||
@@ -1772,7 +1772,7 @@ async fn browse_container(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let music_server =
|
let music_server =
|
||||||
MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT).map_err(|e| {
|
UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT).map_err(|e| {
|
||||||
warn!("Failed to create MusicServer for {}: {}", server_id, e);
|
warn!("Failed to create MusicServer for {}: {}", server_id, e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -1871,7 +1871,7 @@ fn map_snapshot_error(
|
|||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
fn map_openhome_error(
|
fn map_openhome_error(
|
||||||
renderer_id: &RendererId,
|
renderer_id: &ServiceId,
|
||||||
err: anyhow::Error,
|
err: anyhow::Error,
|
||||||
context: &str,
|
context: &str,
|
||||||
) -> (StatusCode, Json<ErrorResponse>) {
|
) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
@@ -1922,7 +1922,7 @@ fn fetch_playback_items(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create MusicServer
|
// Create MusicServer
|
||||||
let music_server = MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?;
|
let music_server = UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?;
|
||||||
|
|
||||||
// Browse the object to get entries
|
// Browse the object to get entries
|
||||||
let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
|
let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
|
||||||
@@ -1959,7 +1959,7 @@ fn protocol_summary(protocol: &RendererProtocol) -> RendererProtocolSummary {
|
|||||||
match protocol {
|
match protocol {
|
||||||
RendererProtocol::UpnpAvOnly => RendererProtocolSummary::Upnp,
|
RendererProtocol::UpnpAvOnly => RendererProtocolSummary::Upnp,
|
||||||
RendererProtocol::OpenHomeOnly => RendererProtocolSummary::Openhome,
|
RendererProtocol::OpenHomeOnly => RendererProtocolSummary::Openhome,
|
||||||
RendererProtocol::Hybrid => RendererProtocolSummary::Hybrid,
|
RendererProtocol::OpenHomeHybrid => RendererProtocolSummary::Hybrid,
|
||||||
RendererProtocol::ChromecastOnly => RendererProtocolSummary::Chromecast,
|
RendererProtocol::ChromecastOnly => RendererProtocolSummary::Chromecast,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,12 @@
|
|||||||
//! - This identity is used by the sync helpers to preserve the current
|
//! - This identity is used by the sync helpers to preserve the current
|
||||||
//! track across queue rebuilds when the MediaServer content changes.
|
//! track across queue rebuilds when the MediaServer content changes.
|
||||||
|
|
||||||
use anyhow::Result;
|
use crate::DeviceId;
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
// ADAPTE ces imports aux modules existants dans pmocontrol.
|
// ADAPTE ces imports aux modules existants dans pmocontrol.
|
||||||
// Exemple probable :
|
// Exemple probable :
|
||||||
// use crate::model::MediaServerId;
|
// use crate::model::MediaServerId;
|
||||||
// use crate::model::TrackMetadata;
|
// use crate::model::TrackMetadata;
|
||||||
use crate::media_server::ServerId as MediaServerId;
|
|
||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
|
|
||||||
/// Canonical representation of a track in a renderer queue.
|
/// Canonical representation of a track in a renderer queue.
|
||||||
@@ -52,7 +51,14 @@ pub struct PlaybackItem {
|
|||||||
///
|
///
|
||||||
/// Typically this is the UDN of the MediaServer device, or an
|
/// Typically this is the UDN of the MediaServer device, or an
|
||||||
/// equivalent logical identifier.
|
/// equivalent logical identifier.
|
||||||
pub media_server_id: MediaServerId,
|
pub media_server_id: DeviceId,
|
||||||
|
|
||||||
|
/// L'ID interne de l'item dans la queue.
|
||||||
|
/// Cet ID n'a de sens que lors du retour d'un snapshot.
|
||||||
|
/// Dans une queue interne, il peut avoir n'importe quelle valeur,
|
||||||
|
/// l'ID qui compte et la position dans le vecteur.
|
||||||
|
/// Par principe, on la peut la mettre égale à usize::MAX.
|
||||||
|
pub backend_id: usize,
|
||||||
|
|
||||||
/// DIDL-Lite `id` attribute of the `item` in the ContentDirectory.
|
/// DIDL-Lite `id` attribute of the `item` in the ContentDirectory.
|
||||||
///
|
///
|
||||||
@@ -159,13 +165,13 @@ pub trait QueueBackend {
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
/// Returns the full snapshot (items + current index) of this queue.
|
/// Returns the full snapshot (items + current index) of this queue.
|
||||||
fn queue_snapshot(&self) -> Result<QueueSnapshot>;
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError>;
|
||||||
|
|
||||||
/// Sets the current index for this queue.
|
/// Sets the current index for this queue.
|
||||||
///
|
///
|
||||||
/// This method only updates the queue structure (pointer to the current
|
/// This method only updates the queue structure (pointer to the current
|
||||||
/// item). It MUST NOT start playback.
|
/// item). It MUST NOT start playback.
|
||||||
fn set_index(&mut self, index: Option<usize>) -> Result<()>;
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
/// Replaces the entire queue with a new list of items and a new
|
/// Replaces the entire queue with a new list of items and a new
|
||||||
/// current index.
|
/// current index.
|
||||||
@@ -173,45 +179,45 @@ pub trait QueueBackend {
|
|||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<()>;
|
) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
/// Returns the item at `index`, if it exists.
|
/// Returns the item at `index`, if it exists.
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>>;
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
|
||||||
|
|
||||||
/// Replaces the item at `index` with `item`.
|
/// Replaces the item at `index` with `item`.
|
||||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()>;
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// DEFAULT HELPERS (backend-agnostic logic)
|
// DEFAULT HELPERS (backend-agnostic logic)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
/// Clears the queue.
|
/// Clears the queue.
|
||||||
fn clear_queue(&mut self) -> Result<()> {
|
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||||
self.replace_queue(Vec::new(), None)
|
self.replace_queue(Vec::new(), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alias for `clear_queue`, semantic name for “empty before rebuild”.
|
/// Alias for `clear_queue`, semantic name for “empty before rebuild”.
|
||||||
fn empty_queue(&mut self) -> Result<()> {
|
fn empty_queue(&mut self) -> Result<(), ControlPointError> {
|
||||||
self.clear_queue()
|
self.clear_queue()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current index, if any.
|
/// Returns the current index, if any.
|
||||||
fn current_index(&self) -> Result<Option<usize>> {
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
Ok(self.queue_snapshot()?.current_index)
|
Ok(self.queue_snapshot()?.current_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the number of items in the queue.
|
/// Returns the number of items in the queue.
|
||||||
fn len(&self) -> Result<usize> {
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
Ok(self.queue_snapshot()?.len())
|
Ok(self.queue_snapshot()?.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the queue is empty.
|
/// Returns `true` if the queue is empty.
|
||||||
fn is_empty(&self) -> Result<bool> {
|
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||||
Ok(self.queue_snapshot()?.is_empty())
|
Ok(self.queue_snapshot()?.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a full snapshot of the queue.
|
/// Returns a full snapshot of the queue.
|
||||||
fn full_snapshot(&self) -> Result<QueueSnapshot> {
|
fn full_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
self.queue_snapshot()
|
self.queue_snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,13 +226,13 @@ pub trait QueueBackend {
|
|||||||
/// The default implementation:
|
/// The default implementation:
|
||||||
/// - takes a snapshot,
|
/// - takes a snapshot,
|
||||||
/// - returns a boxed iterator owning the underlying `Vec`.
|
/// - returns a boxed iterator owning the underlying `Vec`.
|
||||||
fn iter_items(&self) -> Result<Box<dyn Iterator<Item = PlaybackItem>>> {
|
fn iter_items(&self) -> Result<Box<dyn Iterator<Item = PlaybackItem>>, ControlPointError> {
|
||||||
let snapshot = self.queue_snapshot()?;
|
let snapshot = self.queue_snapshot()?;
|
||||||
Ok(Box::new(snapshot.items.into_iter()))
|
Ok(Box::new(snapshot.items.into_iter()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the list of items that come strictly after the current index.
|
/// Returns the list of items that come strictly after the current index.
|
||||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>> {
|
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||||
let snapshot = self.queue_snapshot()?;
|
let snapshot = self.queue_snapshot()?;
|
||||||
let items = match snapshot.current_index {
|
let items = match snapshot.current_index {
|
||||||
None => snapshot.items,
|
None => snapshot.items,
|
||||||
@@ -236,13 +242,13 @@ pub trait QueueBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns how many items remain in the queue after the current index.
|
/// Returns how many items remain in the queue after the current index.
|
||||||
fn upcoming_len(&self) -> Result<usize> {
|
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||||
Ok(self.upcoming_items()?.len())
|
Ok(self.upcoming_items()?.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current item (or the first pending item if no index is set)
|
/// Returns the current item (or the first pending item if no index is set)
|
||||||
/// along with the count of remaining items.
|
/// along with the count of remaining items.
|
||||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>> {
|
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
let snapshot = self.queue_snapshot()?;
|
let snapshot = self.queue_snapshot()?;
|
||||||
let QueueSnapshot {
|
let QueueSnapshot {
|
||||||
items,
|
items,
|
||||||
@@ -274,7 +280,7 @@ pub trait QueueBackend {
|
|||||||
|
|
||||||
/// Advances the queue to the next item (respecting the current index) and
|
/// Advances the queue to the next item (respecting the current index) and
|
||||||
/// returns it with the number of remaining items.
|
/// returns it with the number of remaining items.
|
||||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>> {
|
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
let snapshot = self.queue_snapshot()?;
|
let snapshot = self.queue_snapshot()?;
|
||||||
let QueueSnapshot {
|
let QueueSnapshot {
|
||||||
items,
|
items,
|
||||||
@@ -310,7 +316,7 @@ pub trait QueueBackend {
|
|||||||
///
|
///
|
||||||
/// This method only manipulates the queue structure; it does not
|
/// This method only manipulates the queue structure; it does not
|
||||||
/// start playback.
|
/// start playback.
|
||||||
fn enqueue_items(&mut self, items: Vec<PlaybackItem>, mode: EnqueueMode) -> Result<()> {
|
fn enqueue_items(&mut self, items: Vec<PlaybackItem>, mode: EnqueueMode) -> Result<(), ControlPointError> {
|
||||||
let mut snapshot = self.queue_snapshot()?;
|
let mut snapshot = self.queue_snapshot()?;
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
@@ -337,7 +343,7 @@ pub trait QueueBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the queue with `items` and sets a default index.
|
/// Replaces the queue with `items` and sets a default index.
|
||||||
fn replace_all(&mut self, items: Vec<PlaybackItem>) -> Result<()> {
|
fn replace_all(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
self.replace_queue(Vec::new(), None)
|
self.replace_queue(Vec::new(), None)
|
||||||
} else {
|
} else {
|
||||||
@@ -347,7 +353,7 @@ pub trait QueueBackend {
|
|||||||
|
|
||||||
/// Appends items and, if the queue was previously empty, initializes
|
/// Appends items and, if the queue was previously empty, initializes
|
||||||
/// the current index to `0`.
|
/// the current index to `0`.
|
||||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<()> {
|
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
let was_empty = self.is_empty()?;
|
let was_empty = self.is_empty()?;
|
||||||
let mut snapshot = self.queue_snapshot()?;
|
let mut snapshot = self.queue_snapshot()?;
|
||||||
snapshot.items.extend(items);
|
snapshot.items.extend(items);
|
||||||
@@ -362,7 +368,7 @@ pub trait QueueBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the “next” index.
|
/// Computes the “next” index.
|
||||||
fn next_index(&self) -> Result<Option<usize>> {
|
fn next_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
let len = self.len()?;
|
let len = self.len()?;
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -376,7 +382,7 @@ pub trait QueueBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the “previous” index.
|
/// Computes the “previous” index.
|
||||||
fn previous_index(&self) -> Result<Option<usize>> {
|
fn previous_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
match self.current_index()? {
|
match self.current_index()? {
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
Some(0) => Ok(None),
|
Some(0) => Ok(None),
|
||||||
@@ -385,7 +391,7 @@ pub trait QueueBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Advances the current index to the next item, if any.
|
/// Advances the current index to the next item, if any.
|
||||||
fn advance(&mut self) -> Result<bool> {
|
fn advance(&mut self) -> Result<bool, ControlPointError> {
|
||||||
if let Some(next) = self.next_index()? {
|
if let Some(next) = self.next_index()? {
|
||||||
self.set_index(Some(next))?;
|
self.set_index(Some(next))?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -395,7 +401,7 @@ pub trait QueueBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rewinds the current index to the previous item, if any.
|
/// Rewinds the current index to the previous item, if any.
|
||||||
fn rewind(&mut self) -> Result<bool> {
|
fn rewind(&mut self) -> Result<bool, ControlPointError> {
|
||||||
if let Some(prev) = self.previous_index()? {
|
if let Some(prev) = self.previous_index()? {
|
||||||
self.set_index(Some(prev))?;
|
self.set_index(Some(prev))?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -409,18 +415,18 @@ pub trait QueueBackend {
|
|||||||
&mut self,
|
&mut self,
|
||||||
index: usize,
|
index: usize,
|
||||||
update: impl FnOnce(PlaybackItem) -> PlaybackItem,
|
update: impl FnOnce(PlaybackItem) -> PlaybackItem,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
if let Some(item) = self.get_item(index)? {
|
if let Some(item) = self.get_item(index)? {
|
||||||
let new_item = update(item);
|
let new_item = update(item);
|
||||||
self.replace_item(index, new_item)
|
self.replace_item(index, new_item)
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("Queue index {} out of range", index);
|
Err(ControlPointError::QueueError(format!("Queue index {} out of range", index)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronizes the queue with a new list of items coming from an
|
/// Synchronizes the queue with a new list of items coming from an
|
||||||
/// external MediaServer, trying to preserve the current track.
|
/// external MediaServer, trying to preserve the current track.
|
||||||
fn sync_from_external_preserve_current(&mut self, new_items: Vec<PlaybackItem>) -> Result<()> {
|
fn sync_from_external_preserve_current(&mut self, new_items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
let snapshot = self.queue_snapshot()?;
|
let snapshot = self.queue_snapshot()?;
|
||||||
let current = snapshot
|
let current = snapshot
|
||||||
.current_index
|
.current_index
|
||||||
@@ -15,9 +15,19 @@
|
|||||||
//! - maintains a `current_index`,
|
//! - maintains a `current_index`,
|
||||||
//! - never starts playback (transport control is handled elsewhere).
|
//! - never starts playback (transport control is handled elsewhere).
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
use crate::{
|
||||||
|
DeviceId, RendererInfo,
|
||||||
|
DeviceIdentity,
|
||||||
|
errors::ControlPointError,
|
||||||
|
queue::{
|
||||||
|
MusicQueue,
|
||||||
|
backend::{PlaybackItem, QueueBackend, QueueSnapshot},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/// Internal/local queue implementation.
|
/// Internal/local queue implementation.
|
||||||
///
|
///
|
||||||
@@ -27,35 +37,30 @@ use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
|||||||
///
|
///
|
||||||
/// It does not talk to any remote service. All operations are pure
|
/// It does not talk to any remote service. All operations are pure
|
||||||
/// structural mutations on in-memory data.
|
/// structural mutations on in-memory data.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct InternalQueue {
|
pub struct InternalQueue {
|
||||||
|
renderer_id: DeviceId,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InternalQueue {
|
impl InternalQueue {
|
||||||
/// Creates an empty internal queue.
|
/// Creates an empty internal queue.
|
||||||
pub fn new() -> Self {
|
pub fn new(renderer_id: DeviceId) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
renderer_id,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
current_index: None,
|
current_index: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates an internal queue from an initial list of items.
|
pub fn from_renderer_info(
|
||||||
///
|
info: &RendererInfo,
|
||||||
/// If `set_current_to_first` is `true` and the list is non-empty,
|
) -> Result<InternalQueue, ControlPointError> {
|
||||||
/// the current index is set to `Some(0)`. Otherwise, it is `None`.
|
Ok(InternalQueue::new(
|
||||||
pub fn from_items(items: Vec<PlaybackItem>, set_current_to_first: bool) -> Self {
|
info.id(),
|
||||||
let current_index = if set_current_to_first && !items.is_empty() {
|
),
|
||||||
Some(0)
|
)
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
Self {
|
|
||||||
items,
|
|
||||||
current_index,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exposes a read-only view of the underlying items.
|
/// Exposes a read-only view of the underlying items.
|
||||||
@@ -70,14 +75,19 @@ impl InternalQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl QueueBackend for InternalQueue {
|
impl QueueBackend for InternalQueue {
|
||||||
fn queue_snapshot(&self) -> Result<QueueSnapshot> {
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
|
let mut items = self.items.clone();
|
||||||
|
for (i, item) in items.iter_mut().enumerate() {
|
||||||
|
item.backend_id = i;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(QueueSnapshot {
|
Ok(QueueSnapshot {
|
||||||
items: self.items.clone(),
|
items,
|
||||||
current_index: self.current_index,
|
current_index: self.current_index,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_index(&mut self, index: Option<usize>) -> Result<()> {
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
match index {
|
match index {
|
||||||
None => {
|
None => {
|
||||||
self.current_index = None;
|
self.current_index = None;
|
||||||
@@ -97,17 +107,17 @@ impl QueueBackend for InternalQueue {
|
|||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
self.items = items;
|
self.items = items;
|
||||||
self.current_index = current_index.filter(|&i| i < self.items.len());
|
self.current_index = current_index.filter(|&i| i < self.items.len());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
Ok(self.items.get(index).cloned())
|
Ok(self.items.get(index).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> {
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
if index < self.items.len() {
|
if index < self.items.len() {
|
||||||
self.items[index] = item;
|
self.items[index] = item;
|
||||||
}
|
}
|
||||||
7
pmocontrol/src/queue/mod.rs
Normal file
7
pmocontrol/src/queue/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod music_queue;
|
||||||
|
mod openhome;
|
||||||
|
mod backend;
|
||||||
|
mod interne;
|
||||||
|
|
||||||
|
pub use music_queue::MusicQueue;
|
||||||
|
pub use backend::{PlaybackItem, QueueBackend, QueueSnapshot, EnqueueMode};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use crate::RendererInfo;
|
||||||
|
|
||||||
use crate::control_point::openhome_queue::OpenHomeQueue;
|
use crate::control_point::openhome_queue::OpenHomeQueue;
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
|
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
|
||||||
use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
use crate::queue::backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
use crate::queue_interne::InternalQueue;
|
use crate::queue::interne::InternalQueue;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum MusicQueue {
|
pub enum MusicQueue {
|
||||||
@@ -12,16 +12,22 @@ pub enum MusicQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MusicQueue {
|
impl MusicQueue {
|
||||||
pub fn new_internal() -> Self {
|
|
||||||
MusicQueue::Internal(InternalQueue::default())
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
|
||||||
|
if info.capabilities().has_oh_playlist() {
|
||||||
|
|
||||||
|
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(info)?))
|
||||||
|
} else {
|
||||||
|
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(info)?))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot, ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(),
|
MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(),
|
||||||
_ => Err(anyhow!(
|
_ => Err(ControlPointError::QueueError(format!(
|
||||||
"OpenHome playlist snapshot is only available for OpenHome queues"
|
"OpenHome playlist snapshot is only available for OpenHome queues"
|
||||||
)),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +35,7 @@ impl MusicQueue {
|
|||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicQueue::OpenHome(queue) => queue.replace_entire_playlist(items, current_index),
|
MusicQueue::OpenHome(queue) => queue.replace_entire_playlist(items, current_index),
|
||||||
MusicQueue::Internal(queue) => queue.replace_queue(items, current_index),
|
MusicQueue::Internal(queue) => queue.replace_queue(items, current_index),
|
||||||
@@ -37,21 +43,16 @@ impl MusicQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MusicQueue {
|
|
||||||
fn default() -> Self {
|
|
||||||
MusicQueue::new_internal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl QueueBackend for MusicQueue {
|
impl QueueBackend for MusicQueue {
|
||||||
fn queue_snapshot(&self) -> Result<QueueSnapshot> {
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicQueue::Internal(q) => q.queue_snapshot(),
|
MusicQueue::Internal(q) => q.queue_snapshot(),
|
||||||
MusicQueue::OpenHome(q) => q.queue_snapshot(),
|
MusicQueue::OpenHome(q) => q.queue_snapshot(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_index(&mut self, index: Option<usize>) -> Result<()> {
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicQueue::Internal(q) => q.set_index(index),
|
MusicQueue::Internal(q) => q.set_index(index),
|
||||||
MusicQueue::OpenHome(q) => q.set_index(index),
|
MusicQueue::OpenHome(q) => q.set_index(index),
|
||||||
@@ -62,21 +63,21 @@ impl QueueBackend for MusicQueue {
|
|||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicQueue::Internal(q) => q.replace_queue(items, current_index),
|
MusicQueue::Internal(q) => q.replace_queue(items, current_index),
|
||||||
MusicQueue::OpenHome(q) => q.replace_queue(items, current_index),
|
MusicQueue::OpenHome(q) => q.replace_queue(items, current_index),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicQueue::Internal(q) => q.get_item(index),
|
MusicQueue::Internal(q) => q.get_item(index),
|
||||||
MusicQueue::OpenHome(q) => q.get_item(index),
|
MusicQueue::OpenHome(q) => q.get_item(index),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> {
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicQueue::Internal(q) => q.replace_item(index, item),
|
MusicQueue::Internal(q) => q.replace_item(index, item),
|
||||||
MusicQueue::OpenHome(q) => q.replace_item(index, item),
|
MusicQueue::OpenHome(q) => q.replace_item(index, item),
|
||||||
@@ -1,39 +1,38 @@
|
|||||||
use anyhow::{anyhow, Result};
|
|
||||||
use pmodidl::DIDLLite;
|
use pmodidl::DIDLLite;
|
||||||
use quick_xml::escape::escape;
|
use quick_xml::escape::escape;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::media_server::ServerId;
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::RendererId;
|
|
||||||
use crate::openhome_client::{
|
use crate::openhome_client::{
|
||||||
parse_track_metadata_from_didl, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrackEntry,
|
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrackEntry,
|
||||||
OPENHOME_PLAYLIST_HEAD_ID,
|
parse_track_metadata_from_didl,
|
||||||
};
|
};
|
||||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
|
use crate::{DeviceId, DeviceIdentity, RendererInfo};
|
||||||
|
|
||||||
/// Local mirror of an OpenHome playlist for a single renderer.
|
/// Local mirror of an OpenHome playlist for a single renderer.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct OpenHomeQueue {
|
pub struct OpenHomeQueue {
|
||||||
pub renderer_id: RendererId,
|
renderer_id: DeviceId,
|
||||||
pub playlist: OhPlaylistClient,
|
playlist_client: OhPlaylistClient,
|
||||||
pub info_client: Option<OhInfoClient>,
|
info_client: Option<OhInfoClient>,
|
||||||
pub product_client: Option<OhProductClient>,
|
product_client: Option<OhProductClient>,
|
||||||
pub items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
pub current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
track_ids: Vec<u32>,
|
track_ids: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenHomeQueue {
|
impl OpenHomeQueue {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
renderer_id: RendererId,
|
renderer_id: DeviceId,
|
||||||
playlist: OhPlaylistClient,
|
playlist: OhPlaylistClient,
|
||||||
info_client: Option<OhInfoClient>,
|
info_client: Option<OhInfoClient>,
|
||||||
product_client: Option<OhProductClient>,
|
product_client: Option<OhProductClient>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
renderer_id,
|
renderer_id,
|
||||||
playlist,
|
playlist_client: playlist,
|
||||||
info_client,
|
info_client,
|
||||||
product_client,
|
product_client,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
@@ -42,14 +41,29 @@ impl OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_renderer_info(
|
||||||
|
info: &RendererInfo,
|
||||||
|
) -> Result<OpenHomeQueue, ControlPointError> {
|
||||||
|
let playlist_client = OhPlaylistClient::from_renderer_info(info)?;
|
||||||
|
let info_client = OhInfoClient::from_renderer_info(&info).ok();
|
||||||
|
let product_client = OhProductClient::from_renderer_info(&info).ok();
|
||||||
|
|
||||||
|
Ok(OpenHomeQueue::new(
|
||||||
|
info.id(),
|
||||||
|
playlist_client,
|
||||||
|
info_client,
|
||||||
|
product_client,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Reload the full OpenHome playlist snapshot into local playback items.
|
/// Reload the full OpenHome playlist snapshot into local playback items.
|
||||||
///
|
///
|
||||||
/// This mirrors the logic previously implemented by
|
/// This mirrors the logic previously implemented by
|
||||||
/// `OpenHomeRenderer::snapshot_openhome_playlist` but converts entries
|
/// `OpenHomeRenderer::snapshot_openhome_playlist` but converts entries
|
||||||
/// directly into `PlaybackItem`s.
|
/// directly into `PlaybackItem`s.
|
||||||
pub fn refresh_from_openhome(&mut self) -> Result<()> {
|
pub fn refresh_from_openhome(&mut self) -> Result<(), ControlPointError> {
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
let entries = self.playlist.read_all_tracks()?;
|
let entries = self.playlist_client.read_all_tracks()?;
|
||||||
let mut items = Vec::with_capacity(entries.len());
|
let mut items = Vec::with_capacity(entries.len());
|
||||||
let mut track_ids = Vec::with_capacity(entries.len());
|
let mut track_ids = Vec::with_capacity(entries.len());
|
||||||
|
|
||||||
@@ -59,10 +73,11 @@ impl OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the currently playing track ID from the renderer (may be None if no track is playing)
|
// Get the currently playing track ID from the renderer (may be None if no track is playing)
|
||||||
let current_id = self.playlist.id().ok();
|
let current_id = self.playlist_client.id().ok();
|
||||||
|
|
||||||
// Find the index of the current track in the playlist
|
// Find the index of the current track in the playlist
|
||||||
let current_index = current_id.and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id));
|
let current_index =
|
||||||
|
current_id.and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id));
|
||||||
|
|
||||||
self.items = items;
|
self.items = items;
|
||||||
self.track_ids = track_ids;
|
self.track_ids = track_ids;
|
||||||
@@ -70,7 +85,7 @@ impl OpenHomeQueue {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot, ControlPointError> {
|
||||||
let tracks = self
|
let tracks = self
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -104,7 +119,7 @@ impl OpenHomeQueue {
|
|||||||
self.track_ids.clone()
|
self.track_ids.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn select_track_id(&mut self, id: u32) -> Result<()> {
|
pub fn select_track_id(&mut self, id: u32) -> Result<(), ControlPointError> {
|
||||||
let index = match self.track_ids.iter().position(|&tid| tid == id) {
|
let index = match self.track_ids.iter().position(|&tid| tid == id) {
|
||||||
Some(pos) => pos,
|
Some(pos) => pos,
|
||||||
None => {
|
None => {
|
||||||
@@ -112,33 +127,35 @@ impl OpenHomeQueue {
|
|||||||
self.track_ids
|
self.track_ids
|
||||||
.iter()
|
.iter()
|
||||||
.position(|&tid| tid == id)
|
.position(|&tid| tid == id)
|
||||||
.ok_or_else(|| anyhow!("Unknown OpenHome track id {}", id))?
|
.ok_or_else(|| ControlPointError::QueueError(format!("Unknown OpenHome track id {}", id)))?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
self.playlist.play_id(id)?;
|
self.playlist_client.play_id(id)?;
|
||||||
self.current_index = Some(index);
|
self.current_index = Some(index);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selects and plays a track by its queue index (0-based).
|
/// Selects and plays a track by its queue index (0-based).
|
||||||
pub fn select_track_index(&mut self, index: usize) -> Result<()> {
|
pub fn select_track_index(&mut self, index: usize) -> Result<(), ControlPointError> {
|
||||||
let track_id = self
|
let track_id = self.track_ids.get(index).copied().ok_or_else(|| {
|
||||||
.track_ids
|
ControlPointError::QueueError(format!(
|
||||||
.get(index)
|
"Index {} out of bounds (queue length: {})",
|
||||||
.copied()
|
index,
|
||||||
.ok_or_else(|| anyhow!("Index {} out of bounds (queue length: {})", index, self.track_ids.len()))?;
|
self.track_ids.len()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
self.playlist.play_id(track_id)?;
|
self.playlist_client.play_id(track_id)?;
|
||||||
self.current_index = Some(index);
|
self.current_index = Some(index);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self) -> Result<()> {
|
pub fn clear(&mut self) -> Result<(), ControlPointError> {
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
self.playlist.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
self.track_ids.clear();
|
self.track_ids.clear();
|
||||||
self.current_index = None;
|
self.current_index = None;
|
||||||
@@ -154,9 +171,9 @@ impl OpenHomeQueue {
|
|||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
self.playlist.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
self.track_ids.clear();
|
self.track_ids.clear();
|
||||||
self.current_index = None;
|
self.current_index = None;
|
||||||
@@ -171,7 +188,9 @@ impl OpenHomeQueue {
|
|||||||
|
|
||||||
for item in items {
|
for item in items {
|
||||||
let metadata = build_metadata_xml(&item);
|
let metadata = build_metadata_xml(&item);
|
||||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
let new_id = self
|
||||||
|
.playlist_client
|
||||||
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
rebuilt_ids.push(new_id);
|
rebuilt_ids.push(new_id);
|
||||||
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
||||||
@@ -192,7 +211,7 @@ impl OpenHomeQueue {
|
|||||||
item: PlaybackItem,
|
item: PlaybackItem,
|
||||||
after_id: Option<u32>,
|
after_id: Option<u32>,
|
||||||
play: bool,
|
play: bool,
|
||||||
) -> Result<u32> {
|
) -> Result<u32, ControlPointError> {
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
let metadata_xml = build_metadata_xml(&item);
|
let metadata_xml = build_metadata_xml(&item);
|
||||||
let insert_after = match after_id {
|
let insert_after = match after_id {
|
||||||
@@ -201,11 +220,11 @@ impl OpenHomeQueue {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let new_id = self
|
let new_id = self
|
||||||
.playlist
|
.playlist_client
|
||||||
.insert(insert_after, &item.uri, &metadata_xml)?;
|
.insert(insert_after, &item.uri, &metadata_xml)?;
|
||||||
|
|
||||||
if play {
|
if play {
|
||||||
self.playlist.play_id(new_id)?;
|
self.playlist_client.play_id(new_id)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut insert_index = after_id
|
let mut insert_index = after_id
|
||||||
@@ -239,15 +258,9 @@ impl OpenHomeQueue {
|
|||||||
Ok(new_id)
|
Ok(new_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_playlist_source_selected(&self) -> Result<()> {
|
fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
|
||||||
if let Some(product) = &self.product_client {
|
if let Some(product) = &self.product_client {
|
||||||
product.ensure_playlist_source_selected().map_err(|err| {
|
product.ensure_playlist_source_selected()
|
||||||
anyhow!(
|
|
||||||
"Failed to select OpenHome Playlist source for {}: {}",
|
|
||||||
self.renderer_id.0,
|
|
||||||
err
|
|
||||||
)
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -258,7 +271,8 @@ impl OpenHomeQueue {
|
|||||||
let didl_id = didl_id_from_metadata(&entry.metadata_xml)
|
let didl_id = didl_id_from_metadata(&entry.metadata_xml)
|
||||||
.unwrap_or_else(|| format!("openhome:{}", entry.id));
|
.unwrap_or_else(|| format!("openhome:{}", entry.id));
|
||||||
PlaybackItem {
|
PlaybackItem {
|
||||||
media_server_id: ServerId(format!("openhome:{}", self.renderer_id.0)),
|
media_server_id: DeviceId(format!("openhome:{}", self.renderer_id.0)),
|
||||||
|
backend_id: entry.id as usize,
|
||||||
didl_id,
|
didl_id,
|
||||||
uri: entry.uri.clone(),
|
uri: entry.uri.clone(),
|
||||||
// OpenHome tracks don't provide protocolInfo, use generic default
|
// OpenHome tracks don't provide protocolInfo, use generic default
|
||||||
@@ -269,13 +283,13 @@ impl OpenHomeQueue {
|
|||||||
|
|
||||||
fn item_with_openhome_id(&self, mut item: PlaybackItem, track_id: u32) -> PlaybackItem {
|
fn item_with_openhome_id(&self, mut item: PlaybackItem, track_id: u32) -> PlaybackItem {
|
||||||
item.didl_id = format!("openhome:{}", track_id);
|
item.didl_id = format!("openhome:{}", track_id);
|
||||||
item.media_server_id = ServerId(format!("openhome:{}", self.renderer_id.0));
|
item.media_server_id = DeviceId(format!("openhome:{}", self.renderer_id.0));
|
||||||
item
|
item
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_track_id(&mut self, index: usize) -> Result<u32> {
|
fn ensure_track_id(&mut self, index: usize) -> Result<u32, ControlPointError> {
|
||||||
if index >= self.items.len() {
|
if index >= self.items.len() {
|
||||||
return Err(anyhow!("Index out of bounds in OpenHomeQueue: {}", index));
|
return Err(ControlPointError::OpenHomeError(format!("Index out of bounds in OpenHomeQueue: {}", index)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(id) = self.track_ids.get(index).copied() {
|
if let Some(id) = self.track_ids.get(index).copied() {
|
||||||
@@ -286,7 +300,7 @@ impl OpenHomeQueue {
|
|||||||
self.track_ids
|
self.track_ids
|
||||||
.get(index)
|
.get(index)
|
||||||
.copied()
|
.copied()
|
||||||
.ok_or_else(|| anyhow!("Failed to resolve OpenHome track id at index {}", index))
|
.ok_or_else(|| ControlPointError::OpenHomeError(format!("Failed to resolve OpenHome track id at index {}", index)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CASE 1: Replace queue while preserving the currently playing item as first.
|
/// CASE 1: Replace queue while preserving the currently playing item as first.
|
||||||
@@ -297,13 +311,13 @@ impl OpenHomeQueue {
|
|||||||
new_items: Vec<PlaybackItem>,
|
new_items: Vec<PlaybackItem>,
|
||||||
playing_idx: usize,
|
playing_idx: usize,
|
||||||
playing_id: u32,
|
playing_id: u32,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
// Delete everything except the currently playing item
|
// Delete everything except the currently playing item
|
||||||
// Using delete_id_if_exists() to handle cases where another control point
|
// Using delete_id_if_exists() to handle cases where another control point
|
||||||
// may have already modified the playlist
|
// may have already modified the playlist
|
||||||
for &track_id in self.track_ids.iter().rev() {
|
for &track_id in self.track_ids.iter().rev() {
|
||||||
if track_id != playing_id {
|
if track_id != playing_id {
|
||||||
self.playlist.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +331,9 @@ impl OpenHomeQueue {
|
|||||||
let mut previous_id = playing_id;
|
let mut previous_id = playing_id;
|
||||||
for item in new_items {
|
for item in new_items {
|
||||||
let metadata = build_metadata_xml(&item);
|
let metadata = build_metadata_xml(&item);
|
||||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
let new_id = self
|
||||||
|
.playlist_client
|
||||||
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
rebuilt_ids.push(new_id);
|
rebuilt_ids.push(new_id);
|
||||||
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
||||||
@@ -343,10 +359,13 @@ impl OpenHomeQueue {
|
|||||||
new_items: Vec<PlaybackItem>,
|
new_items: Vec<PlaybackItem>,
|
||||||
pivot_idx_new: usize,
|
pivot_idx_new: usize,
|
||||||
pivot_id: u32,
|
pivot_id: u32,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
// Find the pivot index in our current state
|
// Find the pivot index in our current state
|
||||||
let pivot_idx = self.track_ids.iter().position(|&id| id == pivot_id)
|
let pivot_idx = self
|
||||||
.ok_or_else(|| anyhow!("Pivot track ID {} not found in playlist", pivot_id))?;
|
.track_ids
|
||||||
|
.iter()
|
||||||
|
.position(|&id| id == pivot_id)
|
||||||
|
.ok_or_else(|| ControlPointError::OpenHomeError(format!("Pivot track ID {} not found in playlist", pivot_id)))?;
|
||||||
|
|
||||||
// Split current data at the pivot
|
// Split current data at the pivot
|
||||||
let old_before: Vec<PlaybackItem> = self.items[..pivot_idx].to_vec();
|
let old_before: Vec<PlaybackItem> = self.items[..pivot_idx].to_vec();
|
||||||
@@ -373,7 +392,7 @@ impl OpenHomeQueue {
|
|||||||
"RENDERER OP: DeleteId({})",
|
"RENDERER OP: DeleteId({})",
|
||||||
track_id
|
track_id
|
||||||
);
|
);
|
||||||
self.playlist.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +406,7 @@ impl OpenHomeQueue {
|
|||||||
"RENDERER OP: DeleteId({})",
|
"RENDERER OP: DeleteId({})",
|
||||||
track_id
|
track_id
|
||||||
);
|
);
|
||||||
self.playlist.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +441,9 @@ impl OpenHomeQueue {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let metadata = build_metadata_xml(item);
|
let metadata = build_metadata_xml(item);
|
||||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
let new_id = self
|
||||||
|
.playlist_client
|
||||||
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
after_id = previous_id,
|
after_id = previous_id,
|
||||||
@@ -477,7 +498,9 @@ impl OpenHomeQueue {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let metadata = build_metadata_xml(item);
|
let metadata = build_metadata_xml(item);
|
||||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
let new_id = self
|
||||||
|
.playlist_client
|
||||||
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
after_id = previous_id,
|
after_id = previous_id,
|
||||||
@@ -500,11 +523,11 @@ impl OpenHomeQueue {
|
|||||||
// VERIFICATION: Check that pivot ID is preserved
|
// VERIFICATION: Check that pivot ID is preserved
|
||||||
let final_pivot_id = self.track_ids.get(pivot_idx_new).copied();
|
let final_pivot_id = self.track_ids.get(pivot_idx_new).copied();
|
||||||
if final_pivot_id != Some(pivot_id) {
|
if final_pivot_id != Some(pivot_id) {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::OpenHomeError(format!(
|
||||||
"CRITICAL BUG: Pivot ID changed from {} to {:?} during replace_queue_with_pivot!",
|
"CRITICAL BUG: Pivot ID changed from {} to {:?} during replace_queue_with_pivot!",
|
||||||
pivot_id,
|
pivot_id,
|
||||||
final_pivot_id
|
final_pivot_id
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
@@ -524,7 +547,7 @@ impl OpenHomeQueue {
|
|||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
let (keep_current, keep_desired) = lcs_flags(&self.items, &items);
|
let (keep_current, keep_desired) = lcs_flags(&self.items, &items);
|
||||||
|
|
||||||
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
||||||
@@ -547,7 +570,7 @@ impl OpenHomeQueue {
|
|||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
"Using delete_all() for complete replacement (more robust for live playlists)"
|
"Using delete_all() for complete replacement (more robust for live playlists)"
|
||||||
);
|
);
|
||||||
self.playlist.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
self.track_ids.clear();
|
self.track_ids.clear();
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
} else {
|
} else {
|
||||||
@@ -557,7 +580,7 @@ impl OpenHomeQueue {
|
|||||||
let track_id = self.track_ids[idx];
|
let track_id = self.track_ids[idx];
|
||||||
// Use delete_id_if_exists() to handle cases where another control point
|
// Use delete_id_if_exists() to handle cases where another control point
|
||||||
// may have already modified the playlist
|
// may have already modified the playlist
|
||||||
self.playlist.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
self.track_ids.remove(idx);
|
self.track_ids.remove(idx);
|
||||||
self.items.remove(idx);
|
self.items.remove(idx);
|
||||||
}
|
}
|
||||||
@@ -573,9 +596,9 @@ impl OpenHomeQueue {
|
|||||||
for (idx, item) in items.into_iter().enumerate() {
|
for (idx, item) in items.into_iter().enumerate() {
|
||||||
if keep_desired[idx] {
|
if keep_desired[idx] {
|
||||||
if remaining_idx >= remaining_ids.len() {
|
if remaining_idx >= remaining_ids.len() {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::OpenHomeError(format!(
|
||||||
"OpenHome playlist refresh bookkeeping mismatch (kept entries underflow)"
|
"OpenHome playlist refresh bookkeeping mismatch (kept entries underflow)"
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
let existing_id = remaining_ids[remaining_idx];
|
let existing_id = remaining_ids[remaining_idx];
|
||||||
remaining_idx += 1;
|
remaining_idx += 1;
|
||||||
@@ -584,7 +607,9 @@ impl OpenHomeQueue {
|
|||||||
rebuilt_items.push(self.item_with_openhome_id(item, existing_id));
|
rebuilt_items.push(self.item_with_openhome_id(item, existing_id));
|
||||||
} else {
|
} else {
|
||||||
let metadata = build_metadata_xml(&item);
|
let metadata = build_metadata_xml(&item);
|
||||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
let new_id = self
|
||||||
|
.playlist_client
|
||||||
|
.insert(previous_id, &item.uri, &metadata)?;
|
||||||
previous_id = new_id;
|
previous_id = new_id;
|
||||||
rebuilt_ids.push(new_id);
|
rebuilt_ids.push(new_id);
|
||||||
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
||||||
@@ -592,18 +617,28 @@ impl OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if remaining_idx != remaining_ids.len() {
|
if remaining_idx != remaining_ids.len() {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::OpenHomeError(format!(
|
||||||
"OpenHome playlist refresh bookkeeping mismatch (kept entries overflow)"
|
"OpenHome playlist refresh bookkeeping mismatch (kept entries overflow)"
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let previous_index = self
|
let previous_index = self.current_index.and_then(|idx| {
|
||||||
.current_index
|
if idx < rebuilt_ids.len() {
|
||||||
.and_then(|idx| if idx < rebuilt_ids.len() { Some(idx) } else { None });
|
Some(idx)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
let normalized = current_index
|
let normalized = current_index
|
||||||
.filter(|&i| i < rebuilt_ids.len())
|
.filter(|&i| i < rebuilt_ids.len())
|
||||||
.or(previous_index)
|
.or(previous_index)
|
||||||
.or_else(|| if rebuilt_ids.is_empty() { None } else { Some(0) });
|
.or_else(|| {
|
||||||
|
if rebuilt_ids.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
}
|
||||||
|
});
|
||||||
self.items = rebuilt_items;
|
self.items = rebuilt_items;
|
||||||
self.track_ids = rebuilt_ids;
|
self.track_ids = rebuilt_ids;
|
||||||
self.current_index = normalized;
|
self.current_index = normalized;
|
||||||
@@ -715,19 +750,34 @@ fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec<bool>,
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl QueueBackend for OpenHomeQueue {
|
impl QueueBackend for OpenHomeQueue {
|
||||||
fn queue_snapshot(&self) -> Result<QueueSnapshot> {
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
|
self.ensure_playlist_source_selected()?;
|
||||||
|
let entries = self.playlist_client.read_all_tracks()?;
|
||||||
|
let mut items = Vec::with_capacity(entries.len());
|
||||||
|
|
||||||
|
for entry in &entries {
|
||||||
|
items.push(self.playback_item_from_entry(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the currently playing track ID from the renderer (may be None if no track is playing)
|
||||||
|
let current_id = self.playlist_client.id().ok();
|
||||||
|
|
||||||
|
// Find the index of the current track in the playlist
|
||||||
|
let current_index =
|
||||||
|
current_id.and_then(|id| items.iter().position(|entry_id| entry_id.backend_id == id as usize));
|
||||||
|
|
||||||
Ok(QueueSnapshot {
|
Ok(QueueSnapshot {
|
||||||
items: self.items.clone(),
|
items: self.items.clone(),
|
||||||
current_index: self.current_index,
|
current_index: self.current_index,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_index(&mut self, index: Option<usize>) -> Result<()> {
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
let normalized = index.filter(|&i| i < self.items.len());
|
let normalized = index.filter(|&i| i < self.items.len());
|
||||||
if let Some(idx) = normalized {
|
if let Some(idx) = normalized {
|
||||||
let track_id = self.ensure_track_id(idx)?;
|
let track_id = self.ensure_track_id(idx)?;
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
self.playlist.play_id(track_id)?;
|
self.playlist_client.play_id(track_id)?;
|
||||||
}
|
}
|
||||||
self.current_index = normalized;
|
self.current_index = normalized;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -737,10 +787,10 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<()> {
|
) -> Result<(), ControlPointError> {
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
self.playlist.delete_all()?;
|
self.playlist_client.delete_all()?;
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
self.track_ids.clear();
|
self.track_ids.clear();
|
||||||
self.current_index = None;
|
self.current_index = None;
|
||||||
@@ -753,7 +803,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
self.refresh_from_openhome()?;
|
self.refresh_from_openhome()?;
|
||||||
|
|
||||||
// Get the currently playing track ID from the renderer
|
// Get the currently playing track ID from the renderer
|
||||||
let playing_info = self.playlist.id().ok().and_then(|id| {
|
let playing_info = self.playlist_client.id().ok().and_then(|id| {
|
||||||
self.track_ids
|
self.track_ids
|
||||||
.iter()
|
.iter()
|
||||||
.position(|&tid| tid == id)
|
.position(|&tid| tid == id)
|
||||||
@@ -806,11 +856,11 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
Ok(self.items.get(index).cloned())
|
Ok(self.items.get(index).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> {
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
if index >= self.items.len() {
|
if index >= self.items.len() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -825,12 +875,14 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
|
|
||||||
// Use delete_id_if_exists() to handle cases where another control point
|
// Use delete_id_if_exists() to handle cases where another control point
|
||||||
// may have already modified the playlist
|
// may have already modified the playlist
|
||||||
self.playlist.delete_id_if_exists(track_id)?;
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||||
let metadata = build_metadata_xml(&item);
|
let metadata = build_metadata_xml(&item);
|
||||||
let new_id = self.playlist.insert(before_id, &item.uri, &metadata)?;
|
let new_id = self
|
||||||
|
.playlist_client
|
||||||
|
.insert(before_id, &item.uri, &metadata)?;
|
||||||
|
|
||||||
if self.current_index == Some(index) {
|
if self.current_index == Some(index) {
|
||||||
self.playlist.play_id(new_id)?;
|
self.playlist_client.play_id(new_id)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.items[index] = self.item_with_openhome_id(item, new_id);
|
self.items[index] = self.item_with_openhome_id(item, new_id);
|
||||||
@@ -839,7 +891,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Override enqueue_items to add items directly to the OpenHome playlist.
|
/// Override enqueue_items to add items directly to the OpenHome playlist.
|
||||||
fn enqueue_items(&mut self, items: Vec<PlaybackItem>, mode: EnqueueMode) -> Result<()> {
|
fn enqueue_items(&mut self, items: Vec<PlaybackItem>, mode: EnqueueMode) -> Result<(), ControlPointError> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1,65 +1,100 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use crate::avtransport_client::AvTransportClient;
|
use tracing::warn;
|
||||||
use crate::connection_manager_client::ConnectionManagerClient;
|
|
||||||
use crate::media_server::{MediaServerInfo, ServerId};
|
|
||||||
use crate::model::{RendererId, RendererInfo};
|
|
||||||
use crate::rendering_control_client::RenderingControlClient;
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
use crate::errors::ControlPointError;
|
||||||
enum DeviceKey {
|
use crate::media_server::MusicServer;
|
||||||
Renderer(RendererId),
|
use crate::model::RendererInfo;
|
||||||
Server(ServerId),
|
use crate::music_renderer::MusicRenderer;
|
||||||
|
use crate::{DeviceId, DeviceIdentity, DeviceOnline, UpnpMediaServer};
|
||||||
|
|
||||||
|
const DEFAULT_MAX_AGE: u32 = 1800;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DeviceItem {
|
||||||
|
music_rendrer : Option<Arc<MusicRenderer>>,
|
||||||
|
music_server: Option<Arc<MusicServer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct DeviceRegistry {
|
pub struct DeviceRegistry {
|
||||||
renderers: HashMap<RendererId, RendererInfo>,
|
devices: HashMap<DeviceId, DeviceItem>,
|
||||||
servers: HashMap<ServerId, MediaServerInfo>,
|
udn_index: HashMap<String, DeviceId>,
|
||||||
udn_index: HashMap<String, DeviceKey>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only view / trait for registry access.
|
#[derive(Debug, Clone)]
|
||||||
///
|
|
||||||
/// Pour l’instant, on ne rajoute pas AVTransport ici, on se contente
|
|
||||||
/// d’ajouter les helpers dans `impl DeviceRegistry`.
|
|
||||||
pub trait DeviceRegistryRead {
|
|
||||||
fn list_renderers(&self) -> Vec<RendererInfo>;
|
|
||||||
fn list_servers(&self) -> Vec<MediaServerInfo>;
|
|
||||||
|
|
||||||
fn get_renderer(&self, id: &RendererId) -> Option<RendererInfo>;
|
|
||||||
fn get_server(&self, id: &ServerId) -> Option<MediaServerInfo>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeviceRegistryRead for DeviceRegistry {
|
|
||||||
fn list_renderers(&self) -> Vec<RendererInfo> {
|
|
||||||
self.renderers.values().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_servers(&self) -> Vec<MediaServerInfo> {
|
|
||||||
self.servers.values().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_renderer(&self, id: &RendererId) -> Option<RendererInfo> {
|
|
||||||
self.renderers.get(id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_server(&self, id: &ServerId) -> Option<MediaServerInfo> {
|
|
||||||
self.servers.get(id).cloned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum DeviceUpdate {
|
pub enum DeviceUpdate {
|
||||||
RendererOnline(RendererInfo),
|
OfflineById(DeviceId),
|
||||||
RendererOfflineById(RendererId),
|
OfflineByUdn(String),
|
||||||
RendererOfflineByUdn(String),
|
}
|
||||||
|
|
||||||
ServerOnline(MediaServerInfo),
|
impl DeviceItem {
|
||||||
ServerOfflineById(ServerId),
|
pub fn as_music_renderer(&self) -> Result<Arc<MusicRenderer>, ControlPointError> {
|
||||||
ServerOfflineByUdn(String),
|
match self {
|
||||||
|
DeviceItem::MusicRenderer(renderer) => Ok(Arc::clone(renderer)),
|
||||||
|
_ => Err(ControlPointError::IsNotAMediaRender(format!("{:#?}", self))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_a_music_renderer(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
DeviceItem::MusicRenderer(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_music_server(&self) -> Result<Arc<MusicServer>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
DeviceItem::MusicServer(server) => Ok(Arc::clone(server)),
|
||||||
|
_ => Err(ControlPointError::IsNotAMediaServer(format!("{:#?}", self))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_a_music_server(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
DeviceItem::MusicServer(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceOnline for DeviceItem {
|
||||||
|
fn is_online(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
DeviceItem::MusicRenderer(r) => r.is_online(),
|
||||||
|
DeviceItem::MusicServer(s) => s.is_online(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_seen(&self) -> SystemTime {
|
||||||
|
match self {
|
||||||
|
DeviceItem::MusicRenderer(r) => r.last_seen(),
|
||||||
|
DeviceItem::MusicServer(s) => s.last_seen(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_been_seen_now(&self, max_age: u32) {
|
||||||
|
match self {
|
||||||
|
DeviceItem::MusicRenderer(r) => r.has_been_seen_now(max_age),
|
||||||
|
DeviceItem::MusicServer(s) => s.has_been_seen_now(max_age),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_as_offline(&self) {
|
||||||
|
match self {
|
||||||
|
DeviceItem::MusicRenderer(r) => r.mark_as_offline(),
|
||||||
|
DeviceItem::MusicServer(s) => s.mark_as_offline(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_age(&self) -> u32 {
|
||||||
|
match self {
|
||||||
|
DeviceItem::MusicRenderer(r) => r.max_age(),
|
||||||
|
DeviceItem::MusicServer(s) => s.max_age(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceRegistry {
|
impl DeviceRegistry {
|
||||||
@@ -67,149 +102,99 @@ impl DeviceRegistry {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_renderers(&self) -> Result<Vec<Arc<MusicRenderer>>, ControlPointError> {
|
||||||
|
self.devices
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, item)| item.is_a_music_renderer())
|
||||||
|
.map(|(_, item)| item.as_music_renderer())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_servers(&self) -> Result<Vec<Arc<MusicServer>>, ControlPointError> {
|
||||||
|
self.devices
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, item)| item.is_a_music_server())
|
||||||
|
.map(|(_, item)| item.as_music_server())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_renderer(&self, id: &DeviceId) -> Option<Arc<MusicRenderer>> {
|
||||||
|
self.devices.get(id)?.as_music_renderer().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_server(&self, id: &DeviceId) -> Option<Arc<MusicServer>> {
|
||||||
|
self.devices.get(id)?.as_music_server().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_renderer(&mut self, info: RendererInfo, max_age: u32) {
|
||||||
|
if let Some(existing) = self.devices.get(&info.id()) {
|
||||||
|
existing.has_been_seen_now(max_age);
|
||||||
|
} else {
|
||||||
|
// let renderer = MusicRenderer::from_renderer_info(info);
|
||||||
|
match MusicRenderer::from_renderer_info(info.clone()) {
|
||||||
|
Ok(renderer) => {
|
||||||
|
self.devices
|
||||||
|
.insert(info.id(), DeviceItem::MusicRenderer(renderer));
|
||||||
|
self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to create renderer: {:#?}\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_server(&mut self, info: UpnpMediaServer, max_age: u32) {
|
||||||
|
if let Some(existing) = self.devices.get(&info.id()) {
|
||||||
|
existing.has_been_seen_now(max_age);
|
||||||
|
} else {
|
||||||
|
let server = MusicServer::Upnp(info.clone());
|
||||||
|
self.devices
|
||||||
|
.insert(info.id(), DeviceItem::MusicServer(Arc::new(server)));
|
||||||
|
self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn device_says_byebye(&mut self, udn: &str) {
|
||||||
|
if let Some(device) = self.get_device_by_udn(udn) {
|
||||||
|
device.mark_as_offline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn apply_update(&mut self, update: DeviceUpdate) {
|
pub fn apply_update(&mut self, update: DeviceUpdate) {
|
||||||
match update {
|
match update {
|
||||||
DeviceUpdate::RendererOnline(info) => {
|
DeviceUpdate::OfflineById(id) => {
|
||||||
let udn = info.udn.to_ascii_lowercase();
|
if let Some(renderer) = self.devices.get(&id) {
|
||||||
let id = info.id.clone();
|
renderer.mark_as_offline();
|
||||||
let mut info = info;
|
|
||||||
|
|
||||||
info.online = true;
|
|
||||||
info.last_seen = SystemTime::now();
|
|
||||||
|
|
||||||
self.renderers.insert(id.clone(), info);
|
|
||||||
self.udn_index.insert(udn, DeviceKey::Renderer(id));
|
|
||||||
}
|
|
||||||
DeviceUpdate::RendererOfflineById(id) => {
|
|
||||||
if let Some(info) = self.renderers.get_mut(&id) {
|
|
||||||
info.online = false;
|
|
||||||
info.last_seen = SystemTime::now();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DeviceUpdate::RendererOfflineByUdn(udn) => {
|
DeviceUpdate::OfflineByUdn(udn) => {
|
||||||
let lookup = udn.to_ascii_lowercase();
|
let lookup = udn.to_ascii_lowercase();
|
||||||
if let Some(DeviceKey::Renderer(id)) = self.udn_index.get(&lookup) {
|
if let Some(id) = self.udn_index.get(&lookup) {
|
||||||
if let Some(info) = self.renderers.get_mut(id) {
|
if let Some(renderer) = self.devices.get(id) {
|
||||||
info.online = false;
|
renderer.mark_as_offline();
|
||||||
info.last_seen = SystemTime::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DeviceUpdate::ServerOnline(info) => {
|
|
||||||
let udn = info.udn.to_ascii_lowercase();
|
|
||||||
let id = info.id.clone();
|
|
||||||
let mut info = info;
|
|
||||||
|
|
||||||
info.online = true;
|
|
||||||
info.last_seen = SystemTime::now();
|
|
||||||
|
|
||||||
self.servers.insert(id.clone(), info);
|
|
||||||
self.udn_index.insert(udn, DeviceKey::Server(id));
|
|
||||||
}
|
|
||||||
DeviceUpdate::ServerOfflineById(id) => {
|
|
||||||
if let Some(info) = self.servers.get_mut(&id) {
|
|
||||||
info.online = false;
|
|
||||||
info.last_seen = SystemTime::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DeviceUpdate::ServerOfflineByUdn(udn) => {
|
|
||||||
let lookup = udn.to_ascii_lowercase();
|
|
||||||
if let Some(DeviceKey::Server(id)) = self.udn_index.get(&lookup) {
|
|
||||||
if let Some(info) = self.servers.get_mut(id) {
|
|
||||||
info.online = false;
|
|
||||||
info.last_seen = SystemTime::now();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_device_by_udn(&self, udn: &str) -> Option<DeviceItem> {
|
||||||
|
let lookup = udn.to_ascii_lowercase();
|
||||||
|
self.udn_index
|
||||||
|
.get(&lookup)
|
||||||
|
.and_then(|id| self.devices.get(id).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper: get a renderer by UDN (case-insensitive, via udn_index).
|
/// Helper: get a renderer by UDN (case-insensitive, via udn_index).
|
||||||
pub fn get_renderer_by_udn(&self, udn: &str) -> Option<RendererInfo> {
|
pub fn get_renderer_by_udn(&self, udn: &str) -> Option<Arc<MusicRenderer>> {
|
||||||
let lookup = udn.to_ascii_lowercase();
|
self.get_device_by_udn(udn)
|
||||||
match self.udn_index.get(&lookup) {
|
.and_then(|item| item.as_music_renderer().ok())
|
||||||
Some(DeviceKey::Renderer(id)) => self.renderers.get(id).cloned(),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: get a server by UDN (case-insensitive, via udn_index).
|
/// Helper: get a server by UDN (case-insensitive, via udn_index).
|
||||||
pub fn get_server_by_udn(&self, udn: &str) -> Option<MediaServerInfo> {
|
pub fn get_server_by_udn(&self, udn: &str) -> Option<Arc<MusicServer>> {
|
||||||
let lookup = udn.to_ascii_lowercase();
|
self.get_device_by_udn(udn)
|
||||||
match self.udn_index.get(&lookup) {
|
.and_then(|item| item.as_music_server().ok())
|
||||||
Some(DeviceKey::Server(id)) => self.servers.get(id).cloned(),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construct an AvTransportClient for a given renderer id, if possible.
|
|
||||||
///
|
|
||||||
/// Returns:
|
|
||||||
/// - Some(client) if the renderer exists AND has avtransport_* fields set
|
|
||||||
/// - None if renderer not found or no AVTransport service.
|
|
||||||
pub fn avtransport_client_for_renderer(&self, id: &RendererId) -> Option<AvTransportClient> {
|
|
||||||
let info = self.renderers.get(id)?;
|
|
||||||
|
|
||||||
let service_type = info.avtransport_service_type.as_ref()?;
|
|
||||||
let control_url = info.avtransport_control_url.as_ref()?;
|
|
||||||
|
|
||||||
Some(AvTransportClient::new(
|
|
||||||
control_url.clone(),
|
|
||||||
service_type.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construct an AvTransportClient for a given UDN, if possible.
|
|
||||||
pub fn avtransport_client_for_udn(&self, udn: &str) -> Option<AvTransportClient> {
|
|
||||||
let info = self.get_renderer_by_udn(udn)?;
|
|
||||||
|
|
||||||
let service_type = info.avtransport_service_type?;
|
|
||||||
let control_url = info.avtransport_control_url?;
|
|
||||||
|
|
||||||
Some(AvTransportClient::new(control_url, service_type))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construct a RenderingControlClient for a given renderer id, if possible.
|
|
||||||
pub fn rendering_control_client_for_renderer(
|
|
||||||
&self,
|
|
||||||
id: &RendererId,
|
|
||||||
) -> Option<RenderingControlClient> {
|
|
||||||
let info = self.renderers.get(id)?;
|
|
||||||
|
|
||||||
let service_type = info.rendering_control_service_type.as_ref()?;
|
|
||||||
let control_url = info.rendering_control_control_url.as_ref()?;
|
|
||||||
|
|
||||||
Some(RenderingControlClient::new(
|
|
||||||
control_url.clone(),
|
|
||||||
service_type.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construct a ConnectionManagerClient for a given renderer id, if possible.
|
|
||||||
pub fn connection_manager_client_for_renderer(
|
|
||||||
&self,
|
|
||||||
id: &RendererId,
|
|
||||||
) -> Option<ConnectionManagerClient> {
|
|
||||||
let info = self.renderers.get(id)?;
|
|
||||||
|
|
||||||
let service_type = info.connection_manager_service_type.as_ref()?;
|
|
||||||
let control_url = info.connection_manager_control_url.as_ref()?;
|
|
||||||
|
|
||||||
Some(ConnectionManagerClient::new(
|
|
||||||
control_url.clone(),
|
|
||||||
service_type.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mark_renderer_supports_set_next(&mut self, id: &RendererId) {
|
|
||||||
if let Some(info) = self.renderers.get_mut(id) {
|
|
||||||
if !info.capabilities.has_avtransport_set_next {
|
|
||||||
info.capabilities.has_avtransport_set_next = true;
|
|
||||||
debug!(
|
|
||||||
renderer = id.0.as_str(),
|
|
||||||
"Renderer now marked as supporting AVTransport.SetNextAVTransportURI"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
|
use crate::{
|
||||||
|
errors::ControlPointError,
|
||||||
|
soap_client::{
|
||||||
|
SoapCallResult, ensure_success, extract_child_text, find_child_with_suffix,
|
||||||
|
handle_action_response, invoke_upnp_action, parse_upnp_error,
|
||||||
|
},
|
||||||
|
};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use pmoupnp::soap::SoapEnvelope;
|
use tracing::debug;
|
||||||
use xmltree::{Element, XMLNode};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RenderingControlClient {
|
pub struct RenderingControlClient {
|
||||||
@@ -18,7 +23,8 @@ impl RenderingControlClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// RenderingControl:1 — GetVolume
|
/// RenderingControl:1 — GetVolume
|
||||||
pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result<u16> {
|
pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result<u16, ControlPointError> {
|
||||||
|
debug!("GetVolume: {}", instance_id);
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let args = [
|
let args = [
|
||||||
("InstanceID", instance_id_str.as_str()),
|
("InstanceID", instance_id_str.as_str()),
|
||||||
@@ -30,33 +36,39 @@ impl RenderingControlClient {
|
|||||||
|
|
||||||
ensure_success("GetVolume", &call_result)?;
|
ensure_success("GetVolume", &call_result)?;
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||||
.envelope
|
ControlPointError::UpnpError(format!("Missing SOAP envelope in GetVolume response"))
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetVolume response"))?;
|
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetVolume returned UPnP error {}: {} (HTTP status {})",
|
"GetVolume returned UPnP error {}: {} (HTTP status {})",
|
||||||
err.error_code,
|
err.error_code, err.error_description, call_result.status
|
||||||
err.error_description,
|
)));
|
||||||
call_result.status
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "GetVolumeResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "GetVolumeResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing GetVolumeResponse element in SOAP body"))?;
|
.ok_or_else(|| {
|
||||||
|
ControlPointError::UpnpError(format!(
|
||||||
|
"Missing GetVolumeResponse element in SOAP body"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
let text = extract_child_text(response, "CurrentVolume")?;
|
let text = extract_child_text(response, "CurrentVolume")?;
|
||||||
let volume = text
|
let volume = text.parse::<u16>().map_err(|_| {
|
||||||
.parse::<u16>()
|
ControlPointError::UpnpError(format!("Invalid CurrentVolume value: {}", text))
|
||||||
.map_err(|_| anyhow!("Invalid CurrentVolume value: {}", text))?;
|
})?;
|
||||||
|
|
||||||
Ok(volume)
|
Ok(volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RenderingControl:1 — SetVolume
|
/// RenderingControl:1 — SetVolume
|
||||||
pub fn set_volume(&self, instance_id: u32, channel: &str, volume: u16) -> Result<()> {
|
pub fn set_volume(
|
||||||
|
&self,
|
||||||
|
instance_id: u32,
|
||||||
|
channel: &str,
|
||||||
|
volume: u16,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let volume_str = volume.to_string();
|
let volume_str = volume.to_string();
|
||||||
let args = [
|
let args = [
|
||||||
@@ -72,7 +84,7 @@ impl RenderingControlClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// RenderingControl:1 — GetMute
|
/// RenderingControl:1 — GetMute
|
||||||
pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result<bool> {
|
pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result<bool, ControlPointError> {
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let args = [
|
let args = [
|
||||||
("InstanceID", instance_id_str.as_str()),
|
("InstanceID", instance_id_str.as_str()),
|
||||||
@@ -87,29 +99,29 @@ impl RenderingControlClient {
|
|||||||
let envelope = call_result
|
let envelope = call_result
|
||||||
.envelope
|
.envelope
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetMute response"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response")))?;
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetMute returned UPnP error {}: {} (HTTP status {})",
|
"GetMute returned UPnP error {}: {} (HTTP status {})",
|
||||||
err.error_code,
|
err.error_code,
|
||||||
err.error_description,
|
err.error_description,
|
||||||
call_result.status
|
call_result.status
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing GetMuteResponse element in SOAP body"))?;
|
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetMuteResponse element in SOAP body")))?;
|
||||||
|
|
||||||
let text = extract_child_text(response, "CurrentMute")?;
|
let text = extract_child_text(response, "CurrentMute")?;
|
||||||
let mute = match text.as_str() {
|
let mute = match text.as_str() {
|
||||||
"0" => false,
|
"0" => false,
|
||||||
"1" => true,
|
"1" => true,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(anyhow!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"Invalid CurrentMute value: {} (expected 0 or 1)",
|
"Invalid CurrentMute value: {} (expected 0 or 1)",
|
||||||
text
|
text
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,7 +129,12 @@ impl RenderingControlClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// RenderingControl:1 — SetMute
|
/// RenderingControl:1 — SetMute
|
||||||
pub fn set_mute(&self, instance_id: u32, channel: &str, mute: bool) -> Result<()> {
|
pub fn set_mute(
|
||||||
|
&self,
|
||||||
|
instance_id: u32,
|
||||||
|
channel: &str,
|
||||||
|
mute: bool,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
let instance_id_str = instance_id.to_string();
|
let instance_id_str = instance_id.to_string();
|
||||||
let mute_str = if mute { "1" } else { "0" };
|
let mute_str = if mute { "1" } else { "0" };
|
||||||
let args = [
|
let args = [
|
||||||
@@ -132,100 +149,3 @@ impl RenderingControlClient {
|
|||||||
handle_action_response("SetMute", &call_result)
|
handle_action_response("SetMute", &call_result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
|
||||||
if call_result.status.is_success() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(env) = &call_result.envelope {
|
|
||||||
if let Some(err) = parse_upnp_error(env) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{action} failed with UPnP error {}: {} (HTTP status {})",
|
|
||||||
err.error_code,
|
|
||||||
err.error_description,
|
|
||||||
call_result.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(anyhow!(
|
|
||||||
"{action} failed with HTTP status {} and body: {}",
|
|
||||||
call_result.status,
|
|
||||||
call_result.raw_body
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
|
||||||
ensure_success(action, call_result)?;
|
|
||||||
|
|
||||||
if let Some(env) = &call_result.envelope {
|
|
||||||
if let Some(err) = parse_upnp_error(env) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{action} returned UPnP error {}: {} (HTTP status {})",
|
|
||||||
err.error_code,
|
|
||||||
err.error_description,
|
|
||||||
call_result.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct UpnpError {
|
|
||||||
pub error_code: u32,
|
|
||||||
pub error_description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
|
|
||||||
let fault = find_child_with_suffix(&envelope.body.content, "Fault")?;
|
|
||||||
let detail = find_child_with_suffix(fault, "detail")?;
|
|
||||||
let upnp_error = find_child_with_suffix(detail, "UPnPError")?;
|
|
||||||
|
|
||||||
let error_code_elem = upnp_error.children.iter().find_map(|node| match node {
|
|
||||||
XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem),
|
|
||||||
_ => None,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let binding = error_code_elem.get_text()?;
|
|
||||||
let error_code_text = binding.trim();
|
|
||||||
let error_code = error_code_text.parse::<u32>().ok()?;
|
|
||||||
|
|
||||||
let error_description = upnp_error
|
|
||||||
.children
|
|
||||||
.iter()
|
|
||||||
.find_map(|node| match node {
|
|
||||||
XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => {
|
|
||||||
elem.get_text().map(|t| t.trim().to_string())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| String::from(""));
|
|
||||||
|
|
||||||
Some(UpnpError {
|
|
||||||
error_code,
|
|
||||||
error_description,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
|
|
||||||
parent.children.iter().find_map(|node| match node {
|
|
||||||
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
|
|
||||||
let child = find_child_with_suffix(parent, suffix)
|
|
||||||
.ok_or_else(|| anyhow!("Missing {suffix} element in response"))?;
|
|
||||||
|
|
||||||
let text = child
|
|
||||||
.get_text()
|
|
||||||
.map(|t| t.trim().to_string())
|
|
||||||
.filter(|t| !t.is_empty())
|
|
||||||
.ok_or_else(|| anyhow!("{suffix} element missing text in response"))?;
|
|
||||||
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
|
use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope};
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
use ureq::Agent;
|
use ureq::Agent;
|
||||||
|
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
|
|
||||||
/// Result of a SOAP call:
|
/// Result of a SOAP call:
|
||||||
/// - HTTP status code
|
/// - HTTP status code
|
||||||
/// - raw XML body (always)
|
/// - raw XML body (always)
|
||||||
@@ -34,7 +36,7 @@ pub fn invoke_upnp_action(
|
|||||||
service_type: &str,
|
service_type: &str,
|
||||||
action: &str,
|
action: &str,
|
||||||
args: &[(&str, &str)],
|
args: &[(&str, &str)],
|
||||||
) -> Result<SoapCallResult> {
|
) -> Result<SoapCallResult, ControlPointError> {
|
||||||
invoke_upnp_action_with_timeout(control_url, service_type, action, args, None)
|
invoke_upnp_action_with_timeout(control_url, service_type, action, args, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +46,10 @@ pub fn invoke_upnp_action_with_timeout(
|
|||||||
action: &str,
|
action: &str,
|
||||||
args: &[(&str, &str)],
|
args: &[(&str, &str)],
|
||||||
timeout: Option<Duration>,
|
timeout: Option<Duration>,
|
||||||
) -> Result<SoapCallResult> {
|
) -> Result<SoapCallResult, ControlPointError> {
|
||||||
let body_xml =
|
let body_xml = build_soap_body(action, service_type, args)
|
||||||
build_soap_body(action, service_type, args).context("Failed to build SOAP request body")?;
|
.context("Failed to build SOAP request body")
|
||||||
|
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||||
|
|
||||||
let arg_log = summarize_args_for_log(args);
|
let arg_log = summarize_args_for_log(args);
|
||||||
debug!(
|
debug!(
|
||||||
@@ -77,7 +80,8 @@ pub fn invoke_upnp_action_with_timeout(
|
|||||||
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
||||||
.header("SOAPAction", &soap_action_header)
|
.header("SOAPAction", &soap_action_header)
|
||||||
.send(body_xml)
|
.send(body_xml)
|
||||||
.with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))?;
|
.with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))
|
||||||
|
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
debug!(status = status.as_u16(), "SOAP response received");
|
debug!(status = status.as_u16(), "SOAP response received");
|
||||||
@@ -89,7 +93,8 @@ pub fn invoke_upnp_action_with_timeout(
|
|||||||
let raw_body = response
|
let raw_body = response
|
||||||
.body_mut()
|
.body_mut()
|
||||||
.read_to_string()
|
.read_to_string()
|
||||||
.context("Failed to read SOAP response body")?;
|
.context("Failed to read SOAP response body")
|
||||||
|
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||||
|
|
||||||
// 6. Try to parse SOAP envelope; non-fatal on failure
|
// 6. Try to parse SOAP envelope; non-fatal on failure
|
||||||
let parsed_envelope = parse_soap_envelope(raw_body.as_bytes()).ok();
|
let parsed_envelope = parse_soap_envelope(raw_body.as_bytes()).ok();
|
||||||
@@ -203,7 +208,7 @@ pub fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_child_with_suffix<'a>(
|
pub fn find_child_with_suffix<'a>(
|
||||||
parent: &'a xmltree::Element,
|
parent: &'a xmltree::Element,
|
||||||
suffix: &str,
|
suffix: &str,
|
||||||
) -> Option<&'a xmltree::Element> {
|
) -> Option<&'a xmltree::Element> {
|
||||||
@@ -213,6 +218,235 @@ fn find_child_with_suffix<'a>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn find_child_with_local_name<'a>(
|
||||||
|
parent: &'a xmltree::Element,
|
||||||
|
local: &str,
|
||||||
|
) -> Option<&'a xmltree::Element> {
|
||||||
|
parent.children.iter().find_map(|node| {
|
||||||
|
if let xmltree::XMLNode::Element(elem) = node {
|
||||||
|
if elem.name == local || elem.name.ends_with(&format!(":{}", local)) {
|
||||||
|
return Some(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures the SOAP call succeeded, returns error if HTTP status or UPnP error occurred
|
||||||
|
pub fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<(), ControlPointError> {
|
||||||
|
if call_result.status.is_success() {
|
||||||
|
if let Some(env) = &call_result.envelope {
|
||||||
|
if let Some(err) = parse_upnp_error(env) {
|
||||||
|
return Err(ControlPointError::SoapUpnpParseError(
|
||||||
|
action.to_string(),
|
||||||
|
err.error_code,
|
||||||
|
err.error_description,
|
||||||
|
call_result.status.as_u16() as u32,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(env) = &call_result.envelope {
|
||||||
|
if let Some(err) = parse_upnp_error(env) {
|
||||||
|
return Err(ControlPointError::SoapUpnpParseError(
|
||||||
|
action.to_string(),
|
||||||
|
err.error_code,
|
||||||
|
err.error_description,
|
||||||
|
call_result.status.as_u16() as u32,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ControlPointError::SoapActionWrongBody(
|
||||||
|
action.to_string(),
|
||||||
|
call_result.status.as_u16() as u32,
|
||||||
|
call_result.raw_body.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures success and returns the SOAP envelope (for openhome_client compatibility)
|
||||||
|
pub fn ensure_success_with_envelope<'a>(
|
||||||
|
action: &str,
|
||||||
|
call_result: &'a SoapCallResult,
|
||||||
|
) -> Result<&'a SoapEnvelope, ControlPointError> {
|
||||||
|
if !call_result.status.is_success() {
|
||||||
|
if let Some(env) = &call_result.envelope {
|
||||||
|
if let Some(err) = parse_upnp_error(env) {
|
||||||
|
return Err(ControlPointError::SoapUpnpParseError(
|
||||||
|
action.to_string(),
|
||||||
|
err.error_code,
|
||||||
|
err.error_description,
|
||||||
|
call_result.status.as_u16() as u32,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(ControlPointError::SoapActionWrongBody(
|
||||||
|
action.to_string(),
|
||||||
|
call_result.status.as_u16() as u32,
|
||||||
|
call_result.raw_body.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let envelope = call_result
|
||||||
|
.envelope
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| ControlPointError::SoapNoEnvelop(action.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
|
return Err(ControlPointError::SoapUpnpParseError(
|
||||||
|
action.to_string(),
|
||||||
|
err.error_code,
|
||||||
|
err.error_description,
|
||||||
|
call_result.status.as_u16() as u32,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles action response, ensuring success
|
||||||
|
pub fn handle_action_response(
|
||||||
|
action: &str,
|
||||||
|
call_result: &SoapCallResult,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
ensure_success(action, call_result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_child_text(parent: &xmltree::Element, suffix: &str) -> Result<String, ControlPointError> {
|
||||||
|
let child = find_child_with_suffix(parent, suffix)
|
||||||
|
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
|
||||||
|
|
||||||
|
let text = child
|
||||||
|
.get_text()
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
|
||||||
|
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_child_text_allow_empty(parent: &xmltree::Element, suffix: &str) -> Result<String, ControlPointError> {
|
||||||
|
let child = find_child_with_suffix(parent, suffix)
|
||||||
|
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
|
||||||
|
|
||||||
|
let text = child
|
||||||
|
.get_text()
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_child_text_optional(
|
||||||
|
parent: &xmltree::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();
|
||||||
|
Ok(Some(text))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_child_text_any(
|
||||||
|
parent: &xmltree::Element,
|
||||||
|
suffixes: &[&str],
|
||||||
|
) -> Result<String, ControlPointError> {
|
||||||
|
for suffix in suffixes {
|
||||||
|
if let Ok(text) = extract_child_text(parent, suffix) {
|
||||||
|
return Ok(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ControlPointError::UpnpMissingReturnValue(
|
||||||
|
suffixes.join(" or "),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_child_text_local(parent: &xmltree::Element, local: &str) -> Result<String, ControlPointError> {
|
||||||
|
let child = find_child_with_local_name(parent, local)
|
||||||
|
.ok_or_else(|| ControlPointError::SoapAction(format!("Missing {local} element in response")))?;
|
||||||
|
let text = child
|
||||||
|
.get_text()
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.ok_or_else(|| ControlPointError::SoapAction(format!("{local} element missing text in response")))?;
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_child_text_optional_local(
|
||||||
|
parent: &xmltree::Element,
|
||||||
|
local: &str,
|
||||||
|
) -> Result<Option<String>, ControlPointError> {
|
||||||
|
if let Some(child) = find_child_with_local_name(parent, local) {
|
||||||
|
let text = child
|
||||||
|
.get_text()
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(Some(text))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_visible_flag(value: &str) -> bool {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.eq_ignore_ascii_case("true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if trimmed.eq_ignore_ascii_case("false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
trimmed == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_bool(value: &str) -> bool {
|
||||||
|
value.trim() == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
|
||||||
|
fn value(byte: u8) -> Option<u8> {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' => Some(byte - b'A'),
|
||||||
|
b'a'..=b'z' => Some(byte - b'a' + 26),
|
||||||
|
b'0'..=b'9' => Some(byte - b'0' + 52),
|
||||||
|
b'+' => Some(62),
|
||||||
|
b'/' => Some(63),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let mut buffer: u32 = 0;
|
||||||
|
let mut bits_collected: u8 = 0;
|
||||||
|
|
||||||
|
for byte in input.bytes() {
|
||||||
|
if byte == b'=' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let val =
|
||||||
|
value(byte).ok_or_else(|| ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char)))?;
|
||||||
|
buffer = (buffer << 6) | (val as u32);
|
||||||
|
bits_collected += 6;
|
||||||
|
if bits_collected >= 8 {
|
||||||
|
bits_collected -= 8;
|
||||||
|
let out = (buffer >> bits_collected) & 0xFF;
|
||||||
|
output.push(out as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::build_soap_body;
|
use super::build_soap_body;
|
||||||
|
|||||||
@@ -179,13 +179,13 @@ pub async fn renderer_events_sse(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for info in initial_renderers {
|
for info in initial_renderers {
|
||||||
if info.online {
|
if info.is_online() {
|
||||||
let timestamp = chrono::Utc::now();
|
let timestamp = chrono::Utc::now();
|
||||||
let payload = RendererEventPayload::Online {
|
let payload = RendererEventPayload::Online {
|
||||||
renderer_id: info.id.0.clone(),
|
renderer_id: info.id().0,
|
||||||
friendly_name: info.friendly_name.clone(),
|
friendly_name: info.friendly_name().to_string(),
|
||||||
model_name: info.model_name.clone(),
|
model_name: info. ().to_string(),
|
||||||
manufacturer: info.manufacturer.clone(),
|
manufacturer: info.manufacturer().to_string(),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,32 +4,24 @@ use anyhow::{Result, anyhow};
|
|||||||
|
|
||||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
||||||
use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||||
use crate::music_renderer::op_not_supported;
|
use crate::errors::ControlPointError;
|
||||||
use crate::rendering_control_client::RenderingControlClient;
|
use crate::rendering_control_client::RenderingControlClient;
|
||||||
use crate::{
|
use crate::{
|
||||||
AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererId,
|
AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererInfo,
|
||||||
RendererInfo, TransportControl, VolumeControl,
|
TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// High-level handle representing a renderer and its optional AVTransport client.
|
/// High-level handle representing a renderer and its optional AVTransport client.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UpnpRenderer {
|
pub struct UpnpRenderer {
|
||||||
pub info: RendererInfo,
|
// registry: Arc<RwLock<DeviceRegistry>>,
|
||||||
registry: Arc<RwLock<DeviceRegistry>>,
|
|
||||||
avtransport: Option<AvTransportClient>,
|
avtransport: Option<AvTransportClient>,
|
||||||
rendering_control: Option<RenderingControlClient>,
|
rendering_control: Option<RenderingControlClient>,
|
||||||
connection_manager: Option<ConnectionManagerClient>,
|
connection_manager: Option<ConnectionManagerClient>,
|
||||||
|
has_avtransport_set_next: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpnpRenderer {
|
impl UpnpRenderer {
|
||||||
pub fn id(&self) -> &RendererId {
|
|
||||||
&self.info.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn friendly_name(&self) -> &str {
|
|
||||||
&self.info.friendly_name
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_avtransport(&self) -> bool {
|
pub fn has_avtransport(&self) -> bool {
|
||||||
self.avtransport.is_some()
|
self.avtransport.is_some()
|
||||||
}
|
}
|
||||||
@@ -42,225 +34,158 @@ impl UpnpRenderer {
|
|||||||
self.connection_manager.is_some()
|
self.connection_manager.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if this renderer is known to support SetNextAVTransportURI.
|
pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> {
|
||||||
pub fn supports_set_next(&self) -> bool {
|
self.avtransport.as_ref().ok_or_else(|| {
|
||||||
self.info.capabilities.supports_set_next()
|
ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn avtransport(&self) -> Result<&AvTransportClient> {
|
pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> {
|
||||||
self.avtransport
|
self.rendering_control.as_ref().ok_or_else(|| {
|
||||||
.as_ref()
|
ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer")
|
||||||
.ok_or_else(|| anyhow!("Renderer has no AVTransport service"))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rendering_control(&self) -> Result<&RenderingControlClient> {
|
pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> {
|
||||||
self.rendering_control
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow!("Renderer has no RenderingControl service"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connection_manager(&self) -> Result<&ConnectionManagerClient> {
|
|
||||||
self.connection_manager
|
self.connection_manager
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("Renderer has no ConnectionManager service"))
|
.ok_or_else(|| ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
pub fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.set_av_transport_uri(uri, meta)?;
|
avt.set_av_transport_uri(uri, meta)?;
|
||||||
avt.play(0, "1")
|
avt.play(0, "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI.
|
// /// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI.
|
||||||
pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> {
|
// pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> {
|
||||||
if !self.info.capabilities.has_avtransport {
|
// if !self.has_avtransport_set_next {
|
||||||
return Err(op_not_supported("SetNextAVTransportURI", "AVTransport"));
|
// return Err(op_not_supported("SetNextAVTransportURI", "AVTransport"));
|
||||||
}
|
// }
|
||||||
|
|
||||||
let client = self.avtransport()?;
|
// let client = self.avtransport()?;
|
||||||
let result = client.set_next_av_transport_uri(next_uri, next_meta);
|
// let result = client.set_next_av_transport_uri(next_uri, next_meta);
|
||||||
|
|
||||||
if result.is_ok() {
|
// if result.is_ok() {
|
||||||
let mut reg = self.registry.write().unwrap();
|
// let mut reg = self.registry.write().unwrap();
|
||||||
reg.mark_renderer_supports_set_next(&self.info.id);
|
// reg.mark_renderer_supports_set_next(&self.info.id);
|
||||||
}
|
// }
|
||||||
|
|
||||||
result
|
// result
|
||||||
}
|
// }
|
||||||
|
|
||||||
pub fn pause(&self) -> Result<()> {
|
pub fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.pause(0)
|
avt.pause(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> Result<()> {
|
pub fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.stop(0)
|
avt.stop(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.seek(0, "REL_TIME", hhmmss)
|
avt.seek(0, "REL_TIME", hhmmss)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_master_volume(&self) -> Result<u16> {
|
pub fn get_master_volume(&self) -> Result<u16, ControlPointError> {
|
||||||
let rc = self.rendering_control()?;
|
let rc = self.rendering_control()?;
|
||||||
rc.get_volume(0, "Master")
|
rc.get_volume(0, "Master")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_master_volume(&self, volume: u16) -> Result<()> {
|
pub fn set_master_volume(&self, volume: u16) -> Result<(), ControlPointError> {
|
||||||
let rc = self.rendering_control()?;
|
let rc = self.rendering_control()?;
|
||||||
rc.set_volume(0, "Master", volume)
|
rc.set_volume(0, "Master", volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_master_mute(&self) -> Result<bool> {
|
pub fn get_master_mute(&self) -> Result<bool, ControlPointError> {
|
||||||
let rc = self.rendering_control()?;
|
let rc = self.rendering_control()?;
|
||||||
rc.get_mute(0, "Master")
|
rc.get_mute(0, "Master")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_master_mute(&self, mute: bool) -> Result<()> {
|
pub fn set_master_mute(&self, mute: bool) -> Result<(), ControlPointError> {
|
||||||
let rc = self.rendering_control()?;
|
let rc = self.rendering_control()?;
|
||||||
rc.set_mute(0, "Master", mute)
|
rc.set_mute(0, "Master", mute)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn protocol_info(&self) -> Result<ProtocolInfo> {
|
pub fn protocol_info(&self) -> Result<ProtocolInfo, ControlPointError> {
|
||||||
let cm = self.connection_manager()?;
|
let cm = self.connection_manager()?;
|
||||||
cm.get_protocol_info()
|
cm.get_protocol_info()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connection_ids(&self) -> Result<Vec<i32>> {
|
pub fn connection_ids(&self) -> Result<Vec<i32>, ControlPointError> {
|
||||||
let cm = self.connection_manager()?;
|
let cm = self.connection_manager()?;
|
||||||
cm.get_current_connection_ids()
|
cm.get_current_connection_ids()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connection_info(&self, connection_id: i32) -> Result<ConnectionInfo> {
|
pub fn connection_info(&self, connection_id: i32) -> Result<ConnectionInfo, ControlPointError> {
|
||||||
let cm = self.connection_manager()?;
|
let cm = self.connection_manager()?;
|
||||||
cm.get_current_connection_info(connection_id)
|
cm.get_current_connection_info(connection_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_registry(info: RendererInfo, registry: &Arc<RwLock<DeviceRegistry>>) -> Self {
|
pub fn from_info(info: &RendererInfo) -> Self {
|
||||||
let (avtransport, rendering_control, connection_manager) = {
|
let avtransport = match (
|
||||||
let reg = registry.read().unwrap();
|
info.avtransport_control_url(),
|
||||||
(
|
info.avtransport_service_type(),
|
||||||
reg.avtransport_client_for_renderer(&info.id),
|
) {
|
||||||
reg.rendering_control_client_for_renderer(&info.id),
|
(Some(url), Some(service)) => Some(AvTransportClient::new(url, service)),
|
||||||
reg.connection_manager_client_for_renderer(&info.id),
|
_ => None,
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let rendering_control = match (
|
||||||
|
info.rendering_control_control_url(),
|
||||||
|
info.rendering_control_service_type(),
|
||||||
|
) {
|
||||||
|
(Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let connection_manager = match (
|
||||||
|
info.connection_manager_control_url(),
|
||||||
|
info.connection_manager_service_type(),
|
||||||
|
) {
|
||||||
|
(Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
info,
|
// registry: Arc::clone(registry),
|
||||||
registry: Arc::clone(registry),
|
|
||||||
avtransport,
|
avtransport,
|
||||||
rendering_control,
|
rendering_control,
|
||||||
connection_manager,
|
connection_manager,
|
||||||
|
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::model::{RendererCapabilities, RendererProtocol};
|
|
||||||
use crate::registry::{DeviceRegistry, DeviceUpdate};
|
|
||||||
use std::sync::{Arc, RwLock};
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo {
|
|
||||||
RendererInfo {
|
|
||||||
id: RendererId(format!("renderer-{id_suffix}")),
|
|
||||||
udn: format!("uuid:renderer-{id_suffix}"),
|
|
||||||
friendly_name: format!("Renderer {id_suffix}"),
|
|
||||||
model_name: "Model".into(),
|
|
||||||
manufacturer: "Manufacturer".into(),
|
|
||||||
protocol: RendererProtocol::UpnpAvOnly,
|
|
||||||
capabilities: RendererCapabilities {
|
|
||||||
has_avtransport: with_avtransport,
|
|
||||||
..RendererCapabilities::default()
|
|
||||||
},
|
|
||||||
location: "http://127.0.0.1/device.xml".into(),
|
|
||||||
server_header: "TestServer/1.0".into(),
|
|
||||||
online: true,
|
|
||||||
last_seen: SystemTime::now(),
|
|
||||||
max_age: 1800,
|
|
||||||
avtransport_service_type: with_avtransport
|
|
||||||
.then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()),
|
|
||||||
avtransport_control_url: with_avtransport
|
|
||||||
.then(|| "http://127.0.0.1/avtransport".into()),
|
|
||||||
rendering_control_service_type: None,
|
|
||||||
rendering_control_control_url: None,
|
|
||||||
connection_manager_service_type: None,
|
|
||||||
connection_manager_control_url: None,
|
|
||||||
oh_playlist_service_type: None,
|
|
||||||
oh_playlist_control_url: None,
|
|
||||||
oh_playlist_event_sub_url: None,
|
|
||||||
oh_info_service_type: None,
|
|
||||||
oh_info_control_url: None,
|
|
||||||
oh_info_event_sub_url: None,
|
|
||||||
oh_time_service_type: None,
|
|
||||||
oh_time_control_url: None,
|
|
||||||
oh_time_event_sub_url: None,
|
|
||||||
oh_volume_service_type: None,
|
|
||||||
oh_volume_control_url: None,
|
|
||||||
oh_radio_service_type: None,
|
|
||||||
oh_radio_control_url: None,
|
|
||||||
oh_product_service_type: None,
|
|
||||||
oh_product_control_url: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn registry_with_renderer(info: RendererInfo) -> Arc<RwLock<DeviceRegistry>> {
|
|
||||||
let mut registry = DeviceRegistry::new();
|
|
||||||
registry.apply_update(DeviceUpdate::RendererOnline(info));
|
|
||||||
Arc::new(RwLock::new(registry))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renderer_without_avtransport() {
|
|
||||||
let info = renderer_info("no-avt", false);
|
|
||||||
let registry = registry_with_renderer(info.clone());
|
|
||||||
let renderer = UpnpRenderer::from_registry(info, ®istry);
|
|
||||||
|
|
||||||
assert_eq!(renderer.has_avtransport(), false);
|
|
||||||
assert_eq!(renderer.id().0, "renderer-no-avt");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renderer_with_avtransport() {
|
|
||||||
let info = renderer_info("with-avt", true);
|
|
||||||
let registry = registry_with_renderer(info.clone());
|
|
||||||
let renderer = UpnpRenderer::from_registry(info, ®istry);
|
|
||||||
|
|
||||||
assert!(renderer.has_avtransport());
|
|
||||||
assert_eq!(renderer.friendly_name(), "Renderer with-avt");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
||||||
///
|
///
|
||||||
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
||||||
impl TransportControl for UpnpRenderer {
|
impl TransportControl for UpnpRenderer {
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.set_av_transport_uri(uri, meta)?;
|
avt.set_av_transport_uri(uri, meta)?;
|
||||||
avt.play(0, "1")
|
avt.play(0, "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play(&self) -> Result<()> {
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.play(0, "1")
|
avt.play(0, "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pause(&self) -> Result<()> {
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.pause(0)
|
avt.pause(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&self) -> Result<()> {
|
fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.stop(0)
|
avt.stop(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.seek(0, "REL_TIME", hhmmss)
|
avt.seek(0, "REL_TIME", hhmmss)
|
||||||
}
|
}
|
||||||
@@ -270,19 +195,19 @@ impl TransportControl for UpnpRenderer {
|
|||||||
///
|
///
|
||||||
/// Cette impl se base sur le channel "Master" (InstanceID = 0).
|
/// Cette impl se base sur le channel "Master" (InstanceID = 0).
|
||||||
impl VolumeControl for UpnpRenderer {
|
impl VolumeControl for UpnpRenderer {
|
||||||
fn volume(&self) -> Result<u16> {
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
self.get_master_volume()
|
self.get_master_volume()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&self, v: u16) -> Result<()> {
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||||
self.set_master_volume(v)
|
self.set_master_volume(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool> {
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
self.get_master_mute()
|
self.get_master_mute()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_mute(&self, m: bool) -> Result<()> {
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||||
self.set_master_mute(m)
|
self.set_master_mute(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +216,7 @@ impl VolumeControl for UpnpRenderer {
|
|||||||
///
|
///
|
||||||
/// Utilise AVTransport::GetTransportInfo(InstanceID=0).
|
/// Utilise AVTransport::GetTransportInfo(InstanceID=0).
|
||||||
impl PlaybackStatus for UpnpRenderer {
|
impl PlaybackStatus for UpnpRenderer {
|
||||||
fn playback_state(&self) -> Result<PlaybackState> {
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
let info = avt.get_transport_info(0)?;
|
let info = avt.get_transport_info(0)?;
|
||||||
Ok(PlaybackState::from_upnp_state(
|
Ok(PlaybackState::from_upnp_state(
|
||||||
@@ -301,7 +226,7 @@ impl PlaybackStatus for UpnpRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackPosition for UpnpRenderer {
|
impl PlaybackPosition for UpnpRenderer {
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
let raw: PositionInfo = avt.get_position_info(0)?;
|
let raw: PositionInfo = avt.get_position_info(0)?;
|
||||||
|
|
||||||
@@ -315,3 +240,78 @@ impl PlaybackPosition for UpnpRenderer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #[cfg(test)]
|
||||||
|
// mod tests {
|
||||||
|
// use super::*;
|
||||||
|
// use crate::model::{RendererCapabilities, RendererProtocol};
|
||||||
|
// use crate::registry::{DeviceRegistry, DeviceUpdate};
|
||||||
|
// use std::sync::{Arc, RwLock};
|
||||||
|
// use std::time::SystemTime;
|
||||||
|
|
||||||
|
// // fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo {
|
||||||
|
// // RendererInfo {
|
||||||
|
// // id: RendererId(format!("renderer-{id_suffix}")),
|
||||||
|
// // udn: format!("uuid:renderer-{id_suffix}"),
|
||||||
|
// // friendly_name: format!("Renderer {id_suffix}"),
|
||||||
|
// // model_name: "Model".into(),
|
||||||
|
// // manufacturer: "Manufacturer".into(),
|
||||||
|
// // protocol: RendererProtocol::UpnpAvOnly,
|
||||||
|
// // capabilities: RendererCapabilities {
|
||||||
|
// // has_avtransport: with_avtransport,
|
||||||
|
// // ..RendererCapabilities::default()
|
||||||
|
// // },
|
||||||
|
// // location: "http://127.0.0.1/device.xml".into(),
|
||||||
|
// // server_header: "TestServer/1.0".into(),
|
||||||
|
// // avtransport_service_type: with_avtransport
|
||||||
|
// // .then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()),
|
||||||
|
// // avtransport_control_url: with_avtransport
|
||||||
|
// // .then(|| "http://127.0.0.1/avtransport".into()),
|
||||||
|
// // rendering_control_service_type: None,
|
||||||
|
// // rendering_control_control_url: None,
|
||||||
|
// // connection_manager_service_type: None,
|
||||||
|
// // connection_manager_control_url: None,
|
||||||
|
// // oh_playlist_service_type: None,
|
||||||
|
// // oh_playlist_control_url: None,
|
||||||
|
// // oh_playlist_event_sub_url: None,
|
||||||
|
// // oh_info_service_type: None,
|
||||||
|
// // oh_info_control_url: None,
|
||||||
|
// // oh_info_event_sub_url: None,
|
||||||
|
// // oh_time_service_type: None,
|
||||||
|
// // oh_time_control_url: None,
|
||||||
|
// // oh_time_event_sub_url: None,
|
||||||
|
// // oh_volume_service_type: None,
|
||||||
|
// // oh_volume_control_url: None,
|
||||||
|
// // oh_radio_service_type: None,
|
||||||
|
// // oh_radio_control_url: None,
|
||||||
|
// // oh_product_service_type: None,
|
||||||
|
// // oh_product_control_url: None,
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// fn registry_with_renderer(info: RendererInfo) -> Arc<RwLock<DeviceRegistry>> {
|
||||||
|
// let mut registry = DeviceRegistry::new();
|
||||||
|
// registry.apply_update(DeviceUpdate::RendererOnline(info));
|
||||||
|
// Arc::new(RwLock::new(registry))
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn renderer_without_avtransport() {
|
||||||
|
// let info = renderer_info("no-avt", false);
|
||||||
|
// let registry = registry_with_renderer(info.clone());
|
||||||
|
// let renderer = UpnpRenderer::from_registry(info, ®istry);
|
||||||
|
|
||||||
|
// assert_eq!(renderer.has_avtransport(), false);
|
||||||
|
// assert_eq!(renderer.id().0, "renderer-no-avt");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn renderer_with_avtransport() {
|
||||||
|
// let info = renderer_info("with-avt", true);
|
||||||
|
// let registry = registry_with_renderer(info.clone());
|
||||||
|
// let renderer = UpnpRenderer::from_registry(info, ®istry);
|
||||||
|
|
||||||
|
// assert!(renderer.has_avtransport());
|
||||||
|
// assert_eq!(renderer.friendly_name(), "Renderer with-avt");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use bevy_reflect::Reflect;
|
use bevy_reflect::Reflect;
|
||||||
use pmoutils::ToXmlElement;
|
use pmoutils::ToXmlElement;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::borrow::Cow;
|
use std::{borrow::Cow, time::Instant};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
@@ -36,7 +36,7 @@ pub struct ParsedMetadata<T> {
|
|||||||
/// Timestamp du parsing (exclu de la réflexion car SystemTime n'implémente pas Reflect)
|
/// Timestamp du parsing (exclu de la réflexion car SystemTime n'implémente pas Reflect)
|
||||||
#[reflect(ignore)]
|
#[reflect(ignore)]
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub parsed_at: Option<std::time::SystemTime>,
|
pub parsed_at: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> ParsedMetadata<T> {
|
impl<T> ParsedMetadata<T> {
|
||||||
@@ -44,7 +44,7 @@ impl<T> ParsedMetadata<T> {
|
|||||||
Self {
|
Self {
|
||||||
format: format.into(),
|
format: format.into(),
|
||||||
data,
|
data,
|
||||||
parsed_at: Some(std::time::SystemTime::now()),
|
parsed_at: Some(Instant::now()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -840,7 +840,7 @@ impl TrackMetadata for MemoryTrackMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn touch(&mut self) -> MetadataResult<()> {
|
async fn touch(&mut self) -> MetadataResult<()> {
|
||||||
self.updated_at = Some(SystemTime::now());
|
self.updated_at = Some());
|
||||||
Ok(Some(()))
|
Ok(Some(()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ Chaque chanson contient :
|
|||||||
Pour éviter de télécharger des chansons déjà terminées :
|
Pour éviter de télécharger des chansons déjà terminées :
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let now_ms = SystemTime::now()
|
let now_ms = Instant::now()
|
||||||
.duration_since(UNIX_EPOCH)?
|
.duration_since(UNIX_EPOCH)?
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] }
|
|||||||
pmoplaylist = { path = "../pmoplaylist", optional = true, features = ["pmoserver"] }
|
pmoplaylist = { path = "../pmoplaylist", optional = true, features = ["pmoserver"] }
|
||||||
pmocache = { path = "../pmocache" }
|
pmocache = { path = "../pmocache" }
|
||||||
|
|
||||||
|
async-trait = "0.1"
|
||||||
|
async-recursion = "1.1"
|
||||||
url = "2.5.7"
|
url = "2.5.7"
|
||||||
uuid = "1.18.1"
|
uuid = "1.18.1"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
|
|||||||
@@ -280,44 +280,40 @@ impl DeviceInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Enregistre toutes les URLs du device et de ses services dans le serveur.
|
/// Enregistre toutes les URLs du device et de ses services dans le serveur.
|
||||||
pub fn register_urls<'a>(
|
#[async_recursion::async_recursion]
|
||||||
&'a self,
|
pub async fn register_urls(&self, server: &mut pmoserver::Server) -> Result<(), DeviceError> {
|
||||||
server: &'a mut pmoserver::Server,
|
info!(
|
||||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), DeviceError>> + 'a>> {
|
"✅ Device description for {} available at: {}{}",
|
||||||
Box::pin(async move {
|
self.get_name(),
|
||||||
info!(
|
self.base_url(),
|
||||||
"✅ Device description for {} available at: {}{}",
|
self.description_route(),
|
||||||
self.get_name(),
|
);
|
||||||
self.base_url(),
|
|
||||||
self.description_route(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handler pour la description du device
|
// Handler pour la description du device
|
||||||
let instance_desc = self.clone();
|
let instance_desc = self.clone();
|
||||||
server
|
server
|
||||||
.add_handler(&self.description_route(), move || {
|
.add_handler(&self.description_route(), move || {
|
||||||
let instance = instance_desc.clone();
|
let instance = instance_desc.clone();
|
||||||
async move { instance.description_handler().await }
|
async move { instance.description_handler().await }
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Enregistrer les services
|
// Enregistrer les services
|
||||||
for service in self.services() {
|
for service in self.services() {
|
||||||
service
|
service
|
||||||
.register_urls(server)
|
.register_urls(server)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DeviceError::UrlRegistrationError(e.to_string()))?;
|
.map_err(|e| DeviceError::UrlRegistrationError(e.to_string()))?;
|
||||||
// Start the periodic notifier so buffered state changes are flushed to subscribers.
|
// Start the periodic notifier so buffered state changes are flushed to subscribers.
|
||||||
let _ = service.start_notifier(DEFAULT_NOTIFY_INTERVAL);
|
let _ = service.start_notifier(DEFAULT_NOTIFY_INTERVAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enregistrer les sous-devices
|
// Enregistrer les sous-devices
|
||||||
for device in self.devices() {
|
for device in self.devices() {
|
||||||
device.register_urls(server).await?;
|
device.register_urls(server).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Génère l'élément XML de description du device.
|
/// Génère l'élément XML de description du device.
|
||||||
|
|||||||
@@ -545,16 +545,18 @@ impl ServiceInstance {
|
|||||||
///
|
///
|
||||||
/// Retourne une erreur si l'enregistrement des routes échoue.
|
/// Retourne une erreur si l'enregistrement des routes échoue.
|
||||||
pub async fn register_urls(&self, server: &mut pmoserver::Server) -> Result<(), ServiceError> {
|
pub async fn register_urls(&self, server: &mut pmoserver::Server) -> Result<(), ServiceError> {
|
||||||
let device = self.device.read().unwrap();
|
let (device_name, server_url) = {
|
||||||
let device_name = device
|
let device = self.device.read().unwrap();
|
||||||
.as_ref()
|
let device_name = device
|
||||||
.map(|d| d.get_name().clone())
|
.as_ref()
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.map(|d| d.get_name().clone())
|
||||||
let server_url = device
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
.as_ref()
|
let server_url = device
|
||||||
.map(|d| d.base_url().to_string())
|
.as_ref()
|
||||||
.unwrap_or_default();
|
.map(|d| d.base_url().to_string())
|
||||||
drop(device);
|
.unwrap_or_default();
|
||||||
|
(device_name, server_url)
|
||||||
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"✅ Service description for {}:{} available at : {}{}",
|
"✅ Service description for {}:{} available at : {}{}",
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ static SSDP_SERVER: Lazy<RwLock<Option<SsdpServer>>> = Lazy::new(|| RwLock::new(
|
|||||||
/// // Introspection via le trait UpnpServer
|
/// // Introspection via le trait UpnpServer
|
||||||
/// let devices = server.device_registry().list_devices();
|
/// let devices = server.device_registry().list_devices();
|
||||||
/// ```
|
/// ```
|
||||||
|
#[async_trait::async_trait]
|
||||||
pub trait UpnpServerExt {
|
pub trait UpnpServerExt {
|
||||||
// ========= Device Management (existant) =========
|
// ========= Device Management (existant) =========
|
||||||
|
|
||||||
@@ -226,6 +227,7 @@ pub trait UpnpServerExt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Implémentation du trait UpnpServer pour pmoserver::Server
|
// Implémentation du trait UpnpServer pour pmoserver::Server
|
||||||
|
#[async_trait::async_trait]
|
||||||
impl UpnpServerExt for Server {
|
impl UpnpServerExt for Server {
|
||||||
async fn register_device(
|
async fn register_device(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -308,7 +310,6 @@ impl UpnpServerExt for Server {
|
|||||||
use pmoaudiocache::new_cache;
|
use pmoaudiocache::new_cache;
|
||||||
use pmocache::pmoserver_ext::{create_api_router, create_file_router};
|
use pmocache::pmoserver_ext::{create_api_router, create_file_router};
|
||||||
|
|
||||||
let base_url = self.info().base_url.clone();
|
|
||||||
let cache = Arc::new(new_cache(cache_dir, limit)?);
|
let cache = Arc::new(new_cache(cache_dir, limit)?);
|
||||||
|
|
||||||
// Routes de fichiers pour servir les pistes FLAC
|
// Routes de fichiers pour servir les pistes FLAC
|
||||||
|
|||||||
Reference in New Issue
Block a user