chore(qconnect): checkpoint current poc state

cx
This commit is contained in:
vicrodh committed 2026-03-13 17:28:05 -06:00
1 parent 6d20360dc3
commit de29f1e2e0
92 files changed
+3886 -2433

No files matched your search

+11 -15
View File
@@ -2,8 +2,8 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use mdns_sd::{ServiceDaemon, ServiceEvent};
@@ -59,12 +59,12 @@ impl AirPlayDiscovery {
let mdns = ServiceDaemon::new()
.map_err(|e| AirPlayError::Discovery(format!("Failed to create mDNS daemon: {}", e)))?;
let raop_rx = mdns
.browse(SERVICE_RAOP)
.map_err(|e| AirPlayError::Discovery(format!("Failed to browse RAOP services: {}", e)))?;
let airplay_rx = mdns
.browse(SERVICE_AIRPLAY)
.map_err(|e| AirPlayError::Discovery(format!("Failed to browse AirPlay services: {}", e)))?;
let raop_rx = mdns.browse(SERVICE_RAOP).map_err(|e| {
AirPlayError::Discovery(format!("Failed to browse RAOP services: {}", e))
})?;
let airplay_rx = mdns.browse(SERVICE_AIRPLAY).map_err(|e| {
AirPlayError::Discovery(format!("Failed to browse AirPlay services: {}", e))
})?;
self.running.store(true, Ordering::SeqCst);
@@ -76,12 +76,8 @@ impl AirPlayDiscovery {
state.clone(),
running.clone(),
));
self.handles.push(spawn_receiver(
"airplay",
airplay_rx,
state,
running,
));
self.handles
.push(spawn_receiver("airplay", airplay_rx, state, running));
self.daemon = Some(mdns);
Ok(())
@@ -150,8 +146,8 @@ fn spawn_receiver(
.unwrap_or("Unknown")
.to_string();
let requires_password = parse_password_required(&info);
let ip = pick_ip(info.get_addresses())
.unwrap_or_else(|| "127.0.0.1".to_string());
let ip =
pick_ip(info.get_addresses()).unwrap_or_else(|| "127.0.0.1".to_string());
let device = DiscoveredAirPlayDevice {
id: id.clone(),
+1 -1
View File
@@ -3,6 +3,6 @@
pub mod device;
pub mod discovery;
pub use crate::AirPlayError;
pub use device::{AirPlayConnection, AirPlayMetadata, AirPlayStatus};
pub use discovery::{AirPlayDiscovery, DiscoveredAirPlayDevice};
pub use crate::AirPlayError;
+14 -7
View File
@@ -2,9 +2,11 @@
use serde::{Deserialize, Serialize};
use rust_cast::CastDevice;
use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType, PlayerState, IdleReason};
use rust_cast::channels::media::{
IdleReason, Image, Media, Metadata, MusicTrackMediaMetadata, PlayerState, StreamType,
};
use rust_cast::channels::receiver::{CastDeviceApp, Status as ReceiverStatus};
use rust_cast::CastDevice;
use crate::CastError;
@@ -90,7 +92,10 @@ impl CastDeviceConnection {
/// Disconnect from device
pub fn disconnect(&mut self) -> Result<(), CastError> {
if let Some(session) = &self.session {
let _ = self.device.connection.disconnect(session.transport_id.as_str());
let _ = self
.device
.connection
.disconnect(session.transport_id.as_str());
}
let _ = self.device.connection.disconnect(DEFAULT_RECEIVER_ID);
self.session = None;
@@ -151,7 +156,11 @@ impl CastDeviceConnection {
let status = self
.device
.media
.load(session.transport_id.as_str(), session.session_id.as_str(), &media)
.load(
session.transport_id.as_str(),
session.session_id.as_str(),
&media,
)
.map_err(|e| CastError::Media(e.to_string()))?;
if let Some(entry) = status.entries.first() {
@@ -245,9 +254,7 @@ impl CastDeviceConnection {
IdleReason::Error => "ERROR".to_string(),
});
let duration = entry.media.as_ref()
.and_then(|m| m.duration)
.unwrap_or(0.0) as f64;
let duration = entry.media.as_ref().and_then(|m| m.duration).unwrap_or(0.0) as f64;
let position = entry.current_time.unwrap_or(0.0) as f64;
+1 -1
View File
@@ -2,8 +2,8 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use mdns_sd::{ServiceDaemon, ServiceEvent};
+1 -1
View File
@@ -8,8 +8,8 @@ use std::thread::{self, JoinHandle};
use std::time::Duration;
use crate::chromecast::device::CastDeviceConnection;
use crate::chromecast::{CastPositionInfo, CastStatus, MediaMetadata};
use crate::CastError;
use crate::chromecast::{CastStatus, MediaMetadata, CastPositionInfo};
/// Commands sent to the Chromecast thread
pub enum CastCommand {
+76 -34
View File
@@ -1,9 +1,9 @@
//! DLNA device connection and playback via AVTransport SOAP
use serde::{Deserialize, Serialize};
use rupnp::{Device, Service};
use rupnp::http::Uri;
use rupnp::ssdp::URN;
use rupnp::{Device, Service};
use serde::{Deserialize, Serialize};
use crate::dlna::DiscoveredDlnaDevice;
use crate::DlnaError;
@@ -56,13 +56,11 @@ impl DlnaConnection {
.parse()
.map_err(|e| DlnaError::Connection(format!("Invalid device URL: {}", e)))?;
let parsed_device = Device::from_url(device_url.clone())
.await
.map_err(|e| DlnaError::Connection(format!("Failed to load device description: {}", e)))?;
let parsed_device = Device::from_url(device_url.clone()).await.map_err(|e| {
DlnaError::Connection(format!("Failed to load device description: {}", e))
})?;
let av_transport_service = parsed_device
.find_service(&av_transport_urn())
.cloned();
let av_transport_service = parsed_device.find_service(&av_transport_urn()).cloned();
let rendering_control_service = parsed_device
.find_service(&rendering_control_urn())
.cloned();
@@ -110,12 +108,19 @@ impl DlnaConnection {
}
/// Set the media URI and start playback
pub async fn load_media(&mut self, uri: &str, metadata: &DlnaMetadata, content_type: &str) -> Result<(), DlnaError> {
pub async fn load_media(
&mut self,
uri: &str,
metadata: &DlnaMetadata,
content_type: &str,
) -> Result<(), DlnaError> {
if !self.connected {
return Err(DlnaError::NotConnected);
}
let av_service = self.av_transport_service.as_ref()
let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
// Build DIDL-Lite metadata with actual content type
@@ -133,7 +138,7 @@ impl DlnaConnection {
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
av_service.action(&self.device_url, "SetAVTransportURI", &payload)
av_service.action(&self.device_url, "SetAVTransportURI", &payload),
)
.await
.map_err(|_| {
@@ -158,12 +163,18 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected);
}
let av_service = self.av_transport_service.as_ref()
let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
av_service.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>")
av_service.action(
&self.device_url,
"Play",
"<InstanceID>0</InstanceID><Speed>1</Speed>",
),
)
.await
.map_err(|_| {
@@ -187,7 +198,9 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected);
}
let av_service = self.av_transport_service.as_ref()
let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
av_service
@@ -206,7 +219,9 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected);
}
let av_service = self.av_transport_service.as_ref()
let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
av_service
@@ -231,7 +246,9 @@ impl DlnaConnection {
let seconds = position_secs % 60;
let time_str = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
let av_service = self.av_transport_service.as_ref()
let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
let payload = format!(
@@ -254,8 +271,9 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected);
}
let rc_service = self.rendering_control_service.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no RenderingControl service".to_string()))?;
let rc_service = self.rendering_control_service.as_ref().ok_or_else(|| {
DlnaError::Playback("Device has no RenderingControl service".to_string())
})?;
// DLNA volume is typically 0-100
let dlna_volume = ((volume.clamp(0.0, 1.0) * 100.0) as u32).min(100);
@@ -280,13 +298,19 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected);
}
let av_service = self.av_transport_service.as_ref()
let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
// Get position info
let position_response = tokio::time::timeout(
std::time::Duration::from_secs(5),
av_service.action(&self.device_url, "GetPositionInfo", "<InstanceID>0</InstanceID>")
av_service.action(
&self.device_url,
"GetPositionInfo",
"<InstanceID>0</InstanceID>",
),
)
.await
.map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))?
@@ -295,26 +319,33 @@ impl DlnaConnection {
// Get transport state
let transport_response = tokio::time::timeout(
std::time::Duration::from_secs(5),
av_service.action(&self.device_url, "GetTransportInfo", "<InstanceID>0</InstanceID>")
av_service.action(
&self.device_url,
"GetTransportInfo",
"<InstanceID>0</InstanceID>",
),
)
.await
.map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))?
.map_err(|e| DlnaError::Playback(e.to_string()))?;
// Parse RelTime (position) - format: "HH:MM:SS" or "H:MM:SS"
let rel_time = position_response.get("RelTime")
let rel_time = position_response
.get("RelTime")
.map(|s| s.as_str())
.unwrap_or("0:00:00");
let position_secs = parse_time_string(rel_time);
// Parse TrackDuration - format: "HH:MM:SS"
let track_duration = position_response.get("TrackDuration")
let track_duration = position_response
.get("TrackDuration")
.map(|s| s.as_str())
.unwrap_or("0:00:00");
let duration_secs = parse_time_string(track_duration);
// Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.)
let transport_state = transport_response.get("CurrentTransportState")
let transport_state = transport_response
.get("CurrentTransportState")
.map(|s| s.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string());
@@ -324,7 +355,6 @@ impl DlnaConnection {
transport_state,
})
}
}
/// Parse time string "HH:MM:SS" or "H:MM:SS" to seconds
@@ -336,7 +366,9 @@ fn parse_time_string(time: &str) -> u64 {
let hours: u64 = parts[0].parse().unwrap_or(0);
let minutes: u64 = parts[1].parse().unwrap_or(0);
let seconds: u64 = parts[2].split('.').next()
let seconds: u64 = parts[2]
.split('.')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
@@ -345,15 +377,25 @@ fn parse_time_string(time: &str) -> u64 {
/// Build DIDL-Lite metadata for a track
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata, content_type: &str) -> String {
let duration = metadata.duration_secs.map(|d| {
let hours = d / 3600;
let minutes = (d % 3600) / 60;
let seconds = d % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
}).unwrap_or_else(|| "00:00:00".to_string());
let duration = metadata
.duration_secs
.map(|d| {
let hours = d / 3600;
let minutes = (d % 3600) / 60;
let seconds = d % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
})
.unwrap_or_else(|| "00:00:00".to_string());
let artwork = metadata.artwork_url.as_ref()
.map(|url| format!(r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#, xml_escape(url)))
let artwork = metadata
.artwork_url
.as_ref()
.map(|url| {
format!(
r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#,
xml_escape(url)
)
})
.unwrap_or_default();
// Use actual content type for protocolInfo - critical for DLNA compatibility
+5 -7
View File
@@ -1,8 +1,8 @@
//! DLNA/UPnP device discovery via SSDP
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::TryStreamExt;
@@ -62,7 +62,9 @@ impl DlnaDiscovery {
let handle = tokio::spawn(async move {
while running.load(Ordering::SeqCst) {
let target = SearchTarget::URN(media_renderer_urn());
let discover = rupnp::discover(&target, Duration::from_secs(DISCOVERY_WINDOW_SECS), None).await;
let discover =
rupnp::discover(&target, Duration::from_secs(DISCOVERY_WINDOW_SECS), None)
.await;
if let Ok(stream) = discover {
let mut stream = std::pin::pin!(stream);
@@ -72,11 +74,7 @@ impl DlnaDiscovery {
let manufacturer = device.manufacturer().to_string();
let model = device.model_name().to_string();
let url = device.url().to_string();
let ip = device
.url()
.host()
.unwrap_or("unknown")
.to_string();
let ip = device.url().host().unwrap_or("unknown").to_string();
let mut has_av_transport = false;
let mut has_rendering_control = false;
+1 -1
View File
@@ -3,6 +3,6 @@
pub mod device;
pub mod discovery;
pub use crate::DlnaError;
pub use device::{DlnaConnection, DlnaMetadata, DlnaPositionInfo, DlnaStatus};
pub use discovery::{DiscoveredDlnaDevice, DlnaDiscovery};
pub use crate::DlnaError;
+8 -24
View File
@@ -15,48 +15,32 @@
//! - **MediaServer**: Local HTTP server for streaming audio to cast devices.
//! Supports byte-range requests for seeking.
pub mod airplay;
pub mod chromecast;
pub mod dlna;
pub mod errors;
pub mod media_server;
pub mod chromecast;
pub mod airplay;
pub mod dlna;
// Re-export error types at root
pub use errors::{CastError, AirPlayError, DlnaError};
pub use errors::{AirPlayError, CastError, DlnaError};
// Re-export media server
pub use media_server::MediaServer;
// Re-export Chromecast types
pub use chromecast::{
CastApplication,
CastCommand,
CastDeviceConnection,
CastPositionInfo,
CastStatus,
ChromecastHandle,
DeviceDiscovery,
DiscoveredDevice,
MediaMetadata,
CastApplication, CastCommand, CastDeviceConnection, CastPositionInfo, CastStatus,
ChromecastHandle, DeviceDiscovery, DiscoveredDevice, MediaMetadata,
};
// Re-export AirPlay types
pub use airplay::{
AirPlayConnection,
AirPlayDiscovery,
AirPlayMetadata,
AirPlayStatus,
DiscoveredAirPlayDevice,
AirPlayConnection, AirPlayDiscovery, AirPlayMetadata, AirPlayStatus, DiscoveredAirPlayDevice,
};
// Re-export DLNA types
pub use dlna::{
DiscoveredDlnaDevice,
DlnaConnection,
DlnaDiscovery,
DlnaMetadata,
DlnaPositionInfo,
DlnaStatus,
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaMetadata, DlnaPositionInfo, DlnaStatus,
};
/// Cast device type alias for backwards compatibility
+38 -11
View File
@@ -60,7 +60,12 @@ impl MediaServer {
let base_ip = local_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
let base_url = format_base_url(base_ip, port);
log::info!("MediaServer: Started on {}:{} (base_url: {})", base_ip, port, base_url);
log::info!(
"MediaServer: Started on {}:{} (base_url: {})",
base_ip,
port,
base_url
);
let entries = Arc::new(Mutex::new(HashMap::new()));
let shutdown = Arc::new(AtomicBool::new(false));
@@ -70,11 +75,19 @@ impl MediaServer {
let port_for_log = port;
let handle = thread::spawn(move || {
log::info!("MediaServer: Thread started, listening on port {}", port_for_log);
log::info!(
"MediaServer: Thread started, listening on port {}",
port_for_log
);
while !shutdown_clone.load(Ordering::SeqCst) {
match server.recv_timeout(Duration::from_millis(250)) {
Ok(Some(request)) => {
let response = handle_request(request.method(), request.url(), &request, &entries_clone);
let response = handle_request(
request.method(),
request.url(),
&request,
&entries_clone,
);
let _ = request.respond(response);
}
Ok(None) => {}
@@ -134,10 +147,7 @@ impl MediaServer {
)));
}
let size = path
.metadata()
.map_err(CastError::Io)?
.len();
let size = path.metadata().map_err(CastError::Io)?.len();
let content_type = content_type_for_path(path);
let entry = MediaEntry {
@@ -194,7 +204,12 @@ fn handle_request(
request: &tiny_http::Request,
entries: &Arc<Mutex<HashMap<u64, MediaEntry>>>,
) -> Response<std::io::Cursor<Vec<u8>>> {
log::info!("MediaServer: {} request from {:?} for {}", method, request.remote_addr(), url);
log::info!(
"MediaServer: {} request from {:?} for {}",
method,
request.remote_addr(),
url
);
if method != &Method::Get {
log::warn!("MediaServer: Rejected non-GET request: {}", method);
@@ -204,14 +219,22 @@ fn handle_request(
let id = match parse_audio_id(url) {
Some(id) => id,
None => {
log::warn!("MediaServer: 404 - Could not parse audio ID from URL: {}", url);
log::warn!(
"MediaServer: 404 - Could not parse audio ID from URL: {}",
url
);
return Response::from_data(Vec::new()).with_status_code(StatusCode(404));
}
};
let entry = match entries.lock().ok().and_then(|map| map.get(&id).cloned()) {
Some(entry) => {
log::info!("MediaServer: Found entry for ID {}, content-type: {}, size: {} bytes", id, entry.content_type, entry.size);
log::info!(
"MediaServer: Found entry for ID {}, content-type: {}, size: {} bytes",
id,
entry.content_type,
entry.size
);
entry
}
None => {
@@ -345,7 +368,11 @@ fn format_base_url(ip: IpAddr, port: u16) -> String {
}
fn content_type_for_path(path: &Path) -> String {
match path.extension().and_then(|e| e.to_str()).map(|s| s.to_lowercase()) {
match path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
{
Some(ext) if ext == "flac" => "audio/flac".to_string(),
Some(ext) if ext == "wav" => "audio/wav".to_string(),
Some(ext) if ext == "m4a" => "audio/mp4".to_string(),