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:
2026-01-23 12:18:10 +01:00
parent 8adccaa2df
commit 5c5e1d534b
39 changed files with 3174 additions and 187 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {:?})",

View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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