feat: Implémentation complète de la source Radio France avec intégration UPnP et serveur HTTP
Ajout de la source Radio France avec : - Implémentation du trait MusicSource pour l'intégration UPnP - Routes HTTP API REST pour l'accès aux stations et flux AAC - Proxy streaming avec tracking des connexions - Génération dynamique de l'arborescence UPnP - Cache multi-niveaux (stations, métadonnées, covers) - Support des ~70 stations (standalone, groupes, radios ICI) Fichiers créés : - pmoradiofrance/src/source.rs (implémentation MusicSource) - pmoradiofrance/src/server_ext.rs (routes HTTP et proxy streaming) - pmoradiofrance/assets/radiofrance-logo.webp (logo placeholder) Fichiers modifiés : - pmoradiofrance/src/lib.rs (re-exports et modules) - pmoradiofrance/Cargo.toml (dépendances server) - Cargo.toml (workspace) - pmomediaserver/Cargo.toml (feature radiofrance) - PMOMusic/Cargo.toml (feature radiofrance) - PMOMusic/src/main.rs (enregistrement automatique) Tests et validation : compilation OK, pattern respecté, feature-gating cohérent
This commit is contained in:
@@ -19,7 +19,7 @@ fn ensure_crypto_provider_initialized() {
|
||||
|
||||
INIT.call_once(|| {
|
||||
let _ = rustls::crypto::CryptoProvider::install_default(
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
rustls::crypto::aws_lc_rs::default_provider(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -47,21 +47,25 @@ fn main() {
|
||||
|
||||
// Connect to the device
|
||||
println!("→ Connecting to {}:{}...", chromecast_ip, DEFAULT_PORT);
|
||||
let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
||||
Ok(device) => {
|
||||
println!(" ✓ Connected");
|
||||
device
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let cast_device =
|
||||
match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
||||
Ok(device) => {
|
||||
println!(" ✓ Connected");
|
||||
device
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to receiver channel
|
||||
println!();
|
||||
println!("→ Connecting to receiver channel...");
|
||||
if let Err(e) = cast_device.connection.connect(DEFAULT_DESTINATION_ID.to_string()) {
|
||||
if let Err(e) = cast_device
|
||||
.connection
|
||||
.connect(DEFAULT_DESTINATION_ID.to_string())
|
||||
{
|
||||
eprintln!(" ✗ Failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ fn ensure_crypto_provider_initialized() {
|
||||
|
||||
INIT.call_once(|| {
|
||||
let _ = rustls::crypto::CryptoProvider::install_default(
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
rustls::crypto::aws_lc_rs::default_provider(),
|
||||
);
|
||||
println!("✓ Rustls CryptoProvider initialized");
|
||||
});
|
||||
@@ -88,7 +88,10 @@ fn main() {
|
||||
eprintln!("Usage: {} <chromecast_ip> [media_url]", args[0]);
|
||||
eprintln!("\nExample:");
|
||||
eprintln!(" {} 192.168.1.100", args[0]);
|
||||
eprintln!("\nIf no media URL is provided, will use: {}", TEST_MEDIA_URL);
|
||||
eprintln!(
|
||||
"\nIf no media URL is provided, will use: {}",
|
||||
TEST_MEDIA_URL
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -116,21 +119,25 @@ fn main() {
|
||||
// Step 1: Connect to the device
|
||||
println!("──────────────────────────────────────────────────────────");
|
||||
println!("STEP 1: Connecting to Chromecast...");
|
||||
let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
||||
Ok(device) => {
|
||||
println!("✓ Connected to Chromecast");
|
||||
device
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ Failed to connect: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let cast_device =
|
||||
match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
||||
Ok(device) => {
|
||||
println!("✓ Connected to Chromecast");
|
||||
device
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ Failed to connect: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Connect to the default receiver channel
|
||||
println!();
|
||||
println!("STEP 2: Connecting to receiver channel...");
|
||||
if let Err(e) = cast_device.connection.connect(DEFAULT_DESTINATION_ID.to_string()) {
|
||||
if let Err(e) = cast_device
|
||||
.connection
|
||||
.connect(DEFAULT_DESTINATION_ID.to_string())
|
||||
{
|
||||
eprintln!("✗ Failed to connect channel: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -151,7 +158,10 @@ fn main() {
|
||||
let status = match cast_device.receiver.get_status() {
|
||||
Ok(status) => {
|
||||
println!("✓ Receiver status obtained");
|
||||
println!(" - Volume: {:.0}%", status.volume.level.unwrap_or(0.5) * 100.0);
|
||||
println!(
|
||||
" - Volume: {:.0}%",
|
||||
status.volume.level.unwrap_or(0.5) * 100.0
|
||||
);
|
||||
println!(" - Muted: {}", status.volume.muted.unwrap_or(false));
|
||||
println!(" - Running apps: {}", status.applications.len());
|
||||
status
|
||||
@@ -165,7 +175,10 @@ fn main() {
|
||||
// Step 5: Launch DefaultMediaReceiver
|
||||
println!();
|
||||
println!("STEP 5: Launching DefaultMediaReceiver app...");
|
||||
let app = match cast_device.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) {
|
||||
let app = match cast_device
|
||||
.receiver
|
||||
.launch_app(&CastDeviceApp::DefaultMediaReceiver)
|
||||
{
|
||||
Ok(app) => {
|
||||
println!("✓ App launched successfully");
|
||||
println!(" - App ID: {}", app.app_id);
|
||||
@@ -201,11 +214,10 @@ fn main() {
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
match cast_device.media.load(
|
||||
app.transport_id.as_str(),
|
||||
app.session_id.as_str(),
|
||||
&media,
|
||||
) {
|
||||
match cast_device
|
||||
.media
|
||||
.load(app.transport_id.as_str(), app.session_id.as_str(), &media)
|
||||
{
|
||||
Ok(status) => {
|
||||
println!("✓ Media loaded successfully!");
|
||||
println!(" - Media status entries: {}", status.entries.len());
|
||||
@@ -241,7 +253,10 @@ fn main() {
|
||||
Ok(ChannelMessage::Heartbeat(response)) => {
|
||||
if let HeartbeatResponse::Ping = response {
|
||||
heartbeat_count += 1;
|
||||
println!("[Heartbeat #{:3}] Received Ping, sending Pong...", heartbeat_count);
|
||||
println!(
|
||||
"[Heartbeat #{:3}] Received Ping, sending Pong...",
|
||||
heartbeat_count
|
||||
);
|
||||
|
||||
if let Err(e) = cast_device.heartbeat.pong() {
|
||||
eprintln!("✗ Failed to send pong: {}", e);
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pmocontrol::RendererProtocol;
|
||||
use pmocontrol::{ControlPoint, DeviceRegistryRead, UpnpMediaServer, RendererInfo};
|
||||
use pmocontrol::{ControlPoint, DeviceRegistryRead, RendererInfo, UpnpMediaServer};
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
// Un tout petit logging optionnel
|
||||
|
||||
@@ -19,9 +19,9 @@ use crossterm::terminal::{
|
||||
};
|
||||
use pmocontrol::model::TrackMetadata;
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
||||
UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
RendererEvent, RendererInfo, TransportControl, VolumeControl,
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, PlaybackItem,
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, RendererEvent, RendererInfo,
|
||||
TransportControl, UpnpMediaServer, UpnpMediaServer, VolumeControl,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
|
||||
@@ -10,8 +10,9 @@ use std::time::Duration;
|
||||
use anyhow::{Context, Result};
|
||||
use pmocontrol::model::TrackMetadata;
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
||||
MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent,
|
||||
MusicRendererBackend, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||
UpnpMediaServer, UpnpMediaServer,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossbeam_channel::RecvTimeoutError;
|
||||
use pmocontrol::{ControlPoint, MediaServerEvent, UpnpMediaServer, ServerId};
|
||||
use pmocontrol::{ControlPoint, MediaServerEvent, ServerId, UpnpMediaServer};
|
||||
|
||||
const DISCOVERY_WAIT_SECS: u64 = 5;
|
||||
const MONITOR_DURATION_SECS: u64 = 90;
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use pmocontrol::{
|
||||
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRendererBackend,
|
||||
RendererInfo,
|
||||
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider,
|
||||
MusicRendererBackend, RendererInfo,
|
||||
control_point::ControlPoint,
|
||||
openhome_client::{
|
||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
||||
|
||||
@@ -344,7 +344,12 @@ fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn progress_monitor(renderer: &MusicRendererBackend, label: &str, iterations: usize, interval_secs: u64) {
|
||||
fn progress_monitor(
|
||||
renderer: &MusicRendererBackend,
|
||||
label: &str,
|
||||
iterations: usize,
|
||||
interval_secs: u64,
|
||||
) {
|
||||
println!(
|
||||
"\n[{label}] polling playback state/position {} times (every {} s)...",
|
||||
iterations, interval_secs
|
||||
|
||||
@@ -7,7 +7,9 @@ use std::{
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
arylic_client::{ARYLIC_TCP_PORT, send_command_required}, errors::ControlPointError, linkplay_client::extract_linkplay_host
|
||||
arylic_client::{ARYLIC_TCP_PORT, send_command_required},
|
||||
errors::ControlPointError,
|
||||
linkplay_client::extract_linkplay_host,
|
||||
};
|
||||
|
||||
static DETECTION_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
||||
|
||||
@@ -65,7 +65,10 @@ impl ChromecastDiscoveryManager {
|
||||
.collect();
|
||||
|
||||
if addresses.is_empty() {
|
||||
warn!("No IP address found for Chromecast device: {}", service_name);
|
||||
warn!(
|
||||
"No IP address found for Chromecast device: {}",
|
||||
service_name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,22 +130,19 @@ impl ChromecastDiscoveryManager {
|
||||
|
||||
// Extract friendly name from TXT record "fn" if available
|
||||
// Otherwise, extract from service instance name (PTR record)
|
||||
let friendly_name = txt_records
|
||||
.get("fn")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback: extract from service name, removing the UUID suffix if present
|
||||
service_name
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
let friendly_name = txt_records.get("fn").cloned().unwrap_or_else(|| {
|
||||
// Fallback: extract from service name, removing the UUID suffix if present
|
||||
service_name
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||
|
||||
@@ -3,14 +3,14 @@ use std::time::Duration;
|
||||
|
||||
use quick_xml::{Error as XmlError, Reader, events::Event};
|
||||
use thiserror::Error;
|
||||
use tracing::{debug};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::DeviceId;
|
||||
use crate::discovery::arylic::detect_arylic_tcp;
|
||||
use crate::linkplay_client::{extract_linkplay_host, fetch_status_for_host};
|
||||
use crate::media_server::UpnpMediaServer;
|
||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
||||
use crate::upnp_clients::{AvTransportClient,resolve_control_url};
|
||||
use crate::upnp_clients::{AvTransportClient, resolve_control_url};
|
||||
|
||||
use ureq::Agent;
|
||||
|
||||
@@ -77,7 +77,6 @@ pub struct ParsedDeviceDescription {
|
||||
}
|
||||
|
||||
impl ParsedDeviceDescription {
|
||||
|
||||
/// Fetch and parse the device description.xml at endpoint.location.
|
||||
pub fn new(
|
||||
udn: &str,
|
||||
@@ -108,7 +107,7 @@ impl ParsedDeviceDescription {
|
||||
let mut buf = Vec::new();
|
||||
let mut parsed = ParsedDeviceDescription::default();
|
||||
|
||||
parsed.timeout_secs=timeout_secs;
|
||||
parsed.timeout_secs = timeout_secs;
|
||||
parsed.location = location.to_string();
|
||||
parsed.udn = udn.to_string();
|
||||
parsed.server_header = server_header.to_string();
|
||||
@@ -354,9 +353,7 @@ impl ParsedDeviceDescription {
|
||||
parsed.require_fields()
|
||||
}
|
||||
|
||||
pub fn build_renderer(
|
||||
&self,
|
||||
) -> Option<RendererInfo> {
|
||||
pub fn build_renderer(&self) -> Option<RendererInfo> {
|
||||
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
||||
if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:")
|
||||
&& !device_type.contains("urn:av-openhome-org:device:mediarenderer:")
|
||||
@@ -371,10 +368,16 @@ impl ParsedDeviceDescription {
|
||||
|
||||
let udn = self.udn.to_ascii_lowercase();
|
||||
let mut caps = detect_renderer_capabilities(&self.service_types);
|
||||
if detect_linkplay_http(&self.location, Duration::from_secs(self.timeout_secs.max(1))) {
|
||||
if detect_linkplay_http(
|
||||
&self.location,
|
||||
Duration::from_secs(self.timeout_secs.max(1)),
|
||||
) {
|
||||
caps.has_linkplay_http = true;
|
||||
}
|
||||
if detect_arylic_tcp(&self.location, Duration::from_secs(self.timeout_secs.max(1))) {
|
||||
if detect_arylic_tcp(
|
||||
&self.location,
|
||||
Duration::from_secs(self.timeout_secs.max(1)),
|
||||
) {
|
||||
caps.has_arylic_tcp = true;
|
||||
}
|
||||
let protocol = detect_renderer_protocol(&caps);
|
||||
@@ -390,68 +393,54 @@ impl ParsedDeviceDescription {
|
||||
self.location.clone(),
|
||||
self.server_header.clone(),
|
||||
self.avtransport_service_type.clone(),
|
||||
self
|
||||
.avtransport_control_url
|
||||
self.avtransport_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.rendering_control_service_type.clone(),
|
||||
self
|
||||
.rendering_control_control_url
|
||||
self.rendering_control_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.connection_manager_service_type.clone(),
|
||||
self
|
||||
.connection_manager_control_url
|
||||
self.connection_manager_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.oh_playlist_service_type.clone(),
|
||||
self
|
||||
.oh_playlist_control_url
|
||||
self.oh_playlist_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self
|
||||
.oh_playlist_event_sub_url
|
||||
self.oh_playlist_event_sub_url
|
||||
.as_ref()
|
||||
.map(|url| resolve_control_url(&self.location, url)),
|
||||
self.oh_info_service_type.clone(),
|
||||
self
|
||||
.oh_info_control_url
|
||||
self.oh_info_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self
|
||||
.oh_info_event_sub_url
|
||||
self.oh_info_event_sub_url
|
||||
.as_ref()
|
||||
.map(|url| resolve_control_url(&self.location, url)),
|
||||
self.oh_time_service_type.clone(),
|
||||
self
|
||||
.oh_time_control_url
|
||||
self.oh_time_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self
|
||||
.oh_time_event_sub_url
|
||||
self.oh_time_event_sub_url
|
||||
.as_ref()
|
||||
.map(|url| resolve_control_url(&self.location, url)),
|
||||
self.oh_volume_service_type.clone(),
|
||||
self
|
||||
.oh_volume_control_url
|
||||
self.oh_volume_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.oh_radio_service_type.clone(),
|
||||
self
|
||||
.oh_radio_control_url
|
||||
self.oh_radio_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.oh_product_service_type.clone(),
|
||||
self
|
||||
.oh_product_control_url
|
||||
self.oh_product_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_server(
|
||||
&self,
|
||||
) -> Option<UpnpMediaServer> {
|
||||
pub fn build_server(&self) -> Option<UpnpMediaServer> {
|
||||
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
||||
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
|
||||
return None;
|
||||
@@ -480,14 +469,11 @@ impl ParsedDeviceDescription {
|
||||
self.content_directory_service_type.clone(),
|
||||
content_directory_control_url,
|
||||
))
|
||||
|
||||
}
|
||||
|
||||
/// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present,
|
||||
/// Ok(None) if no AVTransport service was found.
|
||||
pub fn build_avtransport_client(
|
||||
&self,
|
||||
) -> Result<Option<AvTransportClient>, DescriptionError> {
|
||||
pub fn build_avtransport_client(&self) -> Result<Option<AvTransportClient>, DescriptionError> {
|
||||
let service_type = match &self.avtransport_service_type {
|
||||
Some(st) => st.clone(),
|
||||
None => return Ok(None),
|
||||
@@ -678,9 +664,6 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// Detect whether a renderer exposes the LinkPlay HTTP API.
|
||||
pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
||||
let Some(host) = extract_linkplay_host(location) else {
|
||||
@@ -697,4 +680,4 @@ pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -63,17 +63,19 @@ pub fn parse_time_flexible(input: &str) -> Result<u32, ControlPointError> {
|
||||
let parts: Vec<&str> = input.split(':').collect();
|
||||
|
||||
if parts.is_empty() || parts.len() > 3 {
|
||||
return Err(ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS", input)
|
||||
));
|
||||
return Err(ControlPointError::InvalidTimeFormat(format!(
|
||||
"Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS",
|
||||
input
|
||||
)));
|
||||
}
|
||||
|
||||
let mut total = 0u32;
|
||||
for part in parts {
|
||||
let value = part.parse::<u32>().map_err(|_| {
|
||||
ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid numeric value '{}' in time string '{}'", part, input)
|
||||
)
|
||||
ControlPointError::InvalidTimeFormat(format!(
|
||||
"Invalid numeric value '{}' in time string '{}'",
|
||||
part, input
|
||||
))
|
||||
})?;
|
||||
total = total * 60 + value;
|
||||
}
|
||||
@@ -103,33 +105,29 @@ pub fn parse_hhmmss_strict(input: &str) -> Result<u64, ControlPointError> {
|
||||
let parts: Vec<&str> = input.split(':').collect();
|
||||
|
||||
if parts.len() != 3 {
|
||||
return Err(ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid time format '{}': expected exactly HH:MM:SS", input)
|
||||
));
|
||||
return Err(ControlPointError::InvalidTimeFormat(format!(
|
||||
"Invalid time format '{}': expected exactly HH:MM:SS",
|
||||
input
|
||||
)));
|
||||
}
|
||||
|
||||
let hours: u64 = parts[0].parse().map_err(|_| {
|
||||
ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid hour component in '{}'", input)
|
||||
)
|
||||
ControlPointError::InvalidTimeFormat(format!("Invalid hour component in '{}'", input))
|
||||
})?;
|
||||
|
||||
let minutes: u64 = parts[1].parse().map_err(|_| {
|
||||
ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid minute component in '{}'", input)
|
||||
)
|
||||
ControlPointError::InvalidTimeFormat(format!("Invalid minute component in '{}'", input))
|
||||
})?;
|
||||
|
||||
let seconds: u64 = parts[2].parse().map_err(|_| {
|
||||
ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid second component in '{}'", input)
|
||||
)
|
||||
ControlPointError::InvalidTimeFormat(format!("Invalid second component in '{}'", input))
|
||||
})?;
|
||||
|
||||
if minutes >= 60 || seconds >= 60 {
|
||||
return Err(ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid time '{}': minutes and seconds must be < 60", input)
|
||||
));
|
||||
return Err(ControlPointError::InvalidTimeFormat(format!(
|
||||
"Invalid time '{}': minutes and seconds must be < 60",
|
||||
input
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(hours * 3600 + minutes * 60 + seconds)
|
||||
@@ -209,7 +207,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_ms_conversions() {
|
||||
assert_eq!(ms_to_seconds(1000), 1);
|
||||
assert_eq!(ms_to_seconds(1500), 1); // rounds down
|
||||
assert_eq!(ms_to_seconds(1500), 1); // rounds down
|
||||
assert_eq!(ms_to_seconds(999), 0);
|
||||
|
||||
assert_eq!(seconds_to_ms(1), 1000);
|
||||
|
||||
@@ -274,19 +274,19 @@ pub fn ensure_success_with_envelope<'a>(
|
||||
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,
|
||||
));
|
||||
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(),
|
||||
));
|
||||
action.to_string(),
|
||||
call_result.status.as_u16() as u32,
|
||||
call_result.raw_body.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
let envelope = call_result
|
||||
@@ -295,12 +295,12 @@ pub fn ensure_success_with_envelope<'a>(
|
||||
.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,
|
||||
));
|
||||
return Err(ControlPointError::SoapUpnpParseError(
|
||||
action.to_string(),
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status.as_u16() as u32,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(envelope)
|
||||
@@ -314,7 +314,10 @@ pub fn handle_action_response(
|
||||
ensure_success(action, call_result)
|
||||
}
|
||||
|
||||
pub fn extract_child_text(parent: &xmltree::Element, suffix: &str) -> Result<String, ControlPointError> {
|
||||
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()))?;
|
||||
|
||||
@@ -327,7 +330,10 @@ pub fn extract_child_text(parent: &xmltree::Element, suffix: &str) -> Result<Str
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub fn extract_child_text_allow_empty(parent: &xmltree::Element, suffix: &str) -> Result<String, ControlPointError> {
|
||||
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()))?;
|
||||
|
||||
@@ -368,13 +374,19 @@ pub fn extract_child_text_any(
|
||||
))
|
||||
}
|
||||
|
||||
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")))?;
|
||||
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_or_else(|| {
|
||||
ControlPointError::SoapAction(format!("{local} element missing text in response"))
|
||||
})?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
@@ -408,7 +420,6 @@ 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 {
|
||||
@@ -432,8 +443,9 @@ pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
|
||||
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)))?;
|
||||
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 {
|
||||
@@ -446,7 +458,6 @@ pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_soap_body;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
mod openhome_client;
|
||||
mod avtransport_client;
|
||||
mod rendering_control_client;
|
||||
mod connection_manager_client;
|
||||
|
||||
mod openhome_client;
|
||||
mod rendering_control_client;
|
||||
|
||||
pub use crate::upnp_clients::avtransport_client::{AvTransportClient, PositionInfo};
|
||||
pub use crate::upnp_clients::rendering_control_client::RenderingControlClient;
|
||||
pub use crate::upnp_clients::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||
pub use crate::upnp_clients::openhome_client::{
|
||||
OPENHOME_PLAYLIST_HEAD_ID,
|
||||
OhTrackEntry,OhTrack,
|
||||
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient,
|
||||
pub use crate::upnp_clients::connection_manager_client::{
|
||||
ConnectionInfo, ConnectionManagerClient, ProtocolInfo,
|
||||
};
|
||||
pub use crate::upnp_clients::openhome_client::{
|
||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
||||
OhTimeClient, OhTrack, OhTrackEntry, OhVolumeClient,
|
||||
};
|
||||
pub use crate::upnp_clients::rendering_control_client::RenderingControlClient;
|
||||
|
||||
/// Resolve a possibly relative controlURL against the description URL.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user