Implement Chromecast frontend and DLNA AVTransport casting
Frontend: - Add castStore for managing cast state (connect, disconnect, play, pause, etc) - Update CastPicker to use castStore, show connected device banner with disconnect - Wire isCastConnected to NowPlayingBar and ExpandedPlayer for visual indicator - Modify playbackService to cast when connected instead of local playback DLNA Backend: - Implement full AVTransport SOAP client with: - SetAVTransportURI with DIDL-Lite metadata - Play, Pause, Stop, Seek actions - RenderingControl SetVolume - Add dlna_play_track command that fetches stream, downloads audio, and serves via media server - Share media server between Chromecast and DLNA
This commit is contained in:
1 parent
a4c09041a4
commit
1ac6d15551
8 files changed
+1025
-143
No files matched your search
@@ -1,24 +1,29 @@
|
||||
//! Tauri commands for DLNA/UPnP casting (discovery + scaffolding)
|
||||
//! Tauri commands for DLNA/UPnP casting
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::api::models::Quality;
|
||||
use crate::AppState;
|
||||
use crate::cast::dlna::{
|
||||
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaStatus,
|
||||
};
|
||||
use crate::cast::MediaServer;
|
||||
|
||||
/// DLNA state shared across commands
|
||||
pub struct DlnaState {
|
||||
pub discovery: Arc<Mutex<DlnaDiscovery>>,
|
||||
pub connection: Arc<Mutex<Option<DlnaConnection>>>,
|
||||
pub media_server: Arc<Mutex<MediaServer>>,
|
||||
}
|
||||
|
||||
impl DlnaState {
|
||||
pub fn new() -> Result<Self, DlnaError> {
|
||||
pub fn new(media_server: Arc<Mutex<MediaServer>>) -> Result<Self, DlnaError> {
|
||||
Ok(Self {
|
||||
discovery: Arc::new(Mutex::new(DlnaDiscovery::new())),
|
||||
connection: Arc::new(Mutex::new(None)),
|
||||
media_server,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -83,42 +88,134 @@ pub async fn dlna_get_status(state: State<'_, DlnaState>) -> Result<DlnaStatus,
|
||||
Ok(conn.get_status())
|
||||
}
|
||||
|
||||
// === Playback (stubs) ===
|
||||
// === Playback ===
|
||||
|
||||
/// Play a Qobuz track on the DLNA device
|
||||
#[tauri::command]
|
||||
pub async fn dlna_play_track(
|
||||
track_id: u64,
|
||||
metadata: DlnaMetadata,
|
||||
dlna_state: State<'_, DlnaState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Get stream URL from Qobuz
|
||||
let stream_url = {
|
||||
let client = app_state.client.lock().await;
|
||||
client
|
||||
.get_stream_url_with_fallback(track_id, Quality::HiRes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get stream URL: {}", e))?
|
||||
};
|
||||
|
||||
let content_type = stream_url.mime_type.clone();
|
||||
let cache = app_state.audio_cache.clone();
|
||||
|
||||
// Download audio data
|
||||
let audio_data = if let Some(cached) = cache.get(track_id) {
|
||||
cached.data
|
||||
} else {
|
||||
let data = download_audio(&stream_url.url).await?;
|
||||
cache.insert(track_id, data.clone());
|
||||
data
|
||||
};
|
||||
|
||||
// Register with media server to get a URL the DLNA device can access
|
||||
let url = {
|
||||
let mut server = dlna_state.media_server.lock().await;
|
||||
server.register_audio(track_id, audio_data, &content_type);
|
||||
server
|
||||
.get_audio_url(track_id)
|
||||
.ok_or_else(|| "Failed to build media URL".to_string())?
|
||||
};
|
||||
|
||||
// Load media on DLNA device
|
||||
{
|
||||
let mut connection = dlna_state.connection.lock().await;
|
||||
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
|
||||
conn.load_media(&url, &metadata).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Start playback
|
||||
{
|
||||
let mut connection = dlna_state.connection.lock().await;
|
||||
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
|
||||
conn.play().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load media without starting playback (for compatibility with existing castStore)
|
||||
#[tauri::command]
|
||||
pub async fn dlna_load_media(
|
||||
metadata: DlnaMetadata,
|
||||
state: State<'_, DlnaState>,
|
||||
_metadata: DlnaMetadata,
|
||||
_state: State<'_, DlnaState>,
|
||||
) -> Result<(), String> {
|
||||
let mut connection = state.connection.lock().await;
|
||||
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
|
||||
conn.load_media(metadata).map_err(|e| e.to_string())
|
||||
// This is a stub - actual loading happens in dlna_play_track
|
||||
// The castStore calls this but we need the track_id for actual loading
|
||||
log::warn!("dlna_load_media called without track_id - use dlna_play_track instead");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dlna_play(state: State<'_, DlnaState>) -> Result<(), String> {
|
||||
let mut connection = state.connection.lock().await;
|
||||
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
|
||||
conn.play().map_err(|e| e.to_string())
|
||||
conn.play().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dlna_pause(state: State<'_, DlnaState>) -> Result<(), String> {
|
||||
let mut connection = state.connection.lock().await;
|
||||
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
|
||||
conn.pause().map_err(|e| e.to_string())
|
||||
conn.pause().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dlna_stop(state: State<'_, DlnaState>) -> Result<(), String> {
|
||||
let mut connection = state.connection.lock().await;
|
||||
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
|
||||
conn.stop().map_err(|e| e.to_string())
|
||||
conn.stop().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dlna_seek(position_secs: u64, state: State<'_, DlnaState>) -> Result<(), String> {
|
||||
let mut connection = state.connection.lock().await;
|
||||
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
|
||||
conn.seek(position_secs).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dlna_set_volume(volume: f32, state: State<'_, DlnaState>) -> Result<(), String> {
|
||||
let mut connection = state.connection.lock().await;
|
||||
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
|
||||
conn.set_volume(volume).map_err(|e| e.to_string())
|
||||
conn.set_volume(volume).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
|
||||
use std::time::Duration;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch audio: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP error: {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read audio bytes: {}", e))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! DLNA device connection and playback (placeholder)
|
||||
//! DLNA device connection and playback via AVTransport SOAP
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cast::dlna::{DiscoveredDlnaDevice, DlnaError};
|
||||
|
||||
@@ -14,32 +16,78 @@ pub struct DlnaMetadata {
|
||||
pub duration_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// DLNA device status (minimal for now)
|
||||
/// DLNA device status
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DlnaStatus {
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
pub is_connected: bool,
|
||||
pub is_playing: bool,
|
||||
pub current_uri: Option<String>,
|
||||
}
|
||||
|
||||
/// DLNA connection stub (AVTransport integration pending)
|
||||
/// DLNA connection with AVTransport and RenderingControl support
|
||||
pub struct DlnaConnection {
|
||||
device: DiscoveredDlnaDevice,
|
||||
connected: bool,
|
||||
client: Client,
|
||||
// Service control URLs (discovered from device description)
|
||||
av_transport_url: Option<String>,
|
||||
rendering_control_url: Option<String>,
|
||||
// Current media URI
|
||||
current_uri: Option<String>,
|
||||
is_playing: bool,
|
||||
}
|
||||
|
||||
impl DlnaConnection {
|
||||
/// Connect to a DLNA device
|
||||
/// Connect to a DLNA device and discover service URLs
|
||||
pub fn connect(device: DiscoveredDlnaDevice) -> Result<Self, DlnaError> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| DlnaError::Connection(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
// For now, construct URLs based on device URL
|
||||
// The actual control URLs would come from parsing the device description XML
|
||||
let base_url = device.url.trim_end_matches('/');
|
||||
|
||||
// Common UPnP control URL patterns
|
||||
let av_transport_url = if device.has_av_transport {
|
||||
Some(format!("{}/AVTransport/control", base_url))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rendering_control_url = if device.has_rendering_control {
|
||||
Some(format!("{}/RenderingControl/control", base_url))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"DLNA: Connected to {} (AVT: {:?}, RC: {:?})",
|
||||
device.name,
|
||||
av_transport_url.is_some(),
|
||||
rendering_control_url.is_some()
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
connected: true,
|
||||
client,
|
||||
av_transport_url,
|
||||
rendering_control_url,
|
||||
current_uri: None,
|
||||
is_playing: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Disconnect from the device
|
||||
pub fn disconnect(&mut self) -> Result<(), DlnaError> {
|
||||
self.connected = false;
|
||||
self.current_uri = None;
|
||||
self.is_playing = false;
|
||||
log::info!("DLNA: Disconnected from {}", self.device.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -49,39 +97,299 @@ impl DlnaConnection {
|
||||
device_id: self.device.id.clone(),
|
||||
device_name: self.device.name.clone(),
|
||||
is_connected: self.connected,
|
||||
is_playing: self.is_playing,
|
||||
current_uri: self.current_uri.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load media (requires AVTransport integration)
|
||||
pub fn load_media(&mut self, _metadata: DlnaMetadata) -> Result<(), DlnaError> {
|
||||
Err(DlnaError::NotImplemented(
|
||||
"AVTransport integration not implemented".to_string(),
|
||||
))
|
||||
/// Set the media URI and start playback
|
||||
pub async fn load_media(&mut self, uri: &str, metadata: &DlnaMetadata) -> Result<(), DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
|
||||
let av_url = self.av_transport_url.as_ref()
|
||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||
|
||||
// Build DIDL-Lite metadata
|
||||
let didl_metadata = build_didl_metadata(uri, metadata);
|
||||
|
||||
// SetAVTransportURI SOAP action
|
||||
let soap_body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<InstanceID>0</InstanceID>
|
||||
<CurrentURI>{}</CurrentURI>
|
||||
<CurrentURIMetaData>{}</CurrentURIMetaData>
|
||||
</u:SetAVTransportURI>
|
||||
</s:Body>
|
||||
</s:Envelope>"#,
|
||||
xml_escape(uri),
|
||||
xml_escape(&didl_metadata)
|
||||
);
|
||||
|
||||
self.send_soap_action(
|
||||
av_url,
|
||||
"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI",
|
||||
&soap_body,
|
||||
).await?;
|
||||
|
||||
self.current_uri = Some(uri.to_string());
|
||||
log::info!("DLNA: Set URI to {}", uri);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Play/pause/stop controls
|
||||
pub fn play(&mut self) -> Result<(), DlnaError> {
|
||||
Err(DlnaError::NotImplemented(
|
||||
"AVTransport integration not implemented".to_string(),
|
||||
))
|
||||
/// Start/resume playback
|
||||
pub async fn play(&mut self) -> Result<(), DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
|
||||
let av_url = self.av_transport_url.as_ref()
|
||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||
|
||||
let soap_body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<InstanceID>0</InstanceID>
|
||||
<Speed>1</Speed>
|
||||
</u:Play>
|
||||
</s:Body>
|
||||
</s:Envelope>"#;
|
||||
|
||||
self.send_soap_action(
|
||||
av_url,
|
||||
"urn:schemas-upnp-org:service:AVTransport:1#Play",
|
||||
soap_body,
|
||||
).await?;
|
||||
|
||||
self.is_playing = true;
|
||||
log::info!("DLNA: Play");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pause(&mut self) -> Result<(), DlnaError> {
|
||||
Err(DlnaError::NotImplemented(
|
||||
"AVTransport integration not implemented".to_string(),
|
||||
))
|
||||
/// Pause playback
|
||||
pub async fn pause(&mut self) -> Result<(), DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
|
||||
let av_url = self.av_transport_url.as_ref()
|
||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||
|
||||
let soap_body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:Pause xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<InstanceID>0</InstanceID>
|
||||
</u:Pause>
|
||||
</s:Body>
|
||||
</s:Envelope>"#;
|
||||
|
||||
self.send_soap_action(
|
||||
av_url,
|
||||
"urn:schemas-upnp-org:service:AVTransport:1#Pause",
|
||||
soap_body,
|
||||
).await?;
|
||||
|
||||
self.is_playing = false;
|
||||
log::info!("DLNA: Pause");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) -> Result<(), DlnaError> {
|
||||
Err(DlnaError::NotImplemented(
|
||||
"AVTransport integration not implemented".to_string(),
|
||||
))
|
||||
/// Stop playback
|
||||
pub async fn stop(&mut self) -> Result<(), DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
|
||||
let av_url = self.av_transport_url.as_ref()
|
||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||
|
||||
let soap_body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<InstanceID>0</InstanceID>
|
||||
</u:Stop>
|
||||
</s:Body>
|
||||
</s:Envelope>"#;
|
||||
|
||||
self.send_soap_action(
|
||||
av_url,
|
||||
"urn:schemas-upnp-org:service:AVTransport:1#Stop",
|
||||
soap_body,
|
||||
).await?;
|
||||
|
||||
self.is_playing = false;
|
||||
self.current_uri = None;
|
||||
log::info!("DLNA: Stop");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Volume control (0.0 - 1.0)
|
||||
pub fn set_volume(&mut self, _volume: f32) -> Result<(), DlnaError> {
|
||||
Err(DlnaError::NotImplemented(
|
||||
"RenderingControl integration not implemented".to_string(),
|
||||
))
|
||||
/// Seek to position
|
||||
pub async fn seek(&mut self, position_secs: u64) -> Result<(), DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
|
||||
let av_url = self.av_transport_url.as_ref()
|
||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||
|
||||
let hours = position_secs / 3600;
|
||||
let minutes = (position_secs % 3600) / 60;
|
||||
let seconds = position_secs % 60;
|
||||
let time_str = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
|
||||
|
||||
let soap_body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:Seek xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<InstanceID>0</InstanceID>
|
||||
<Unit>REL_TIME</Unit>
|
||||
<Target>{}</Target>
|
||||
</u:Seek>
|
||||
</s:Body>
|
||||
</s:Envelope>"#,
|
||||
time_str
|
||||
);
|
||||
|
||||
self.send_soap_action(
|
||||
av_url,
|
||||
"urn:schemas-upnp-org:service:AVTransport:1#Seek",
|
||||
&soap_body,
|
||||
).await?;
|
||||
|
||||
log::info!("DLNA: Seek to {}", time_str);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set volume (0.0 - 1.0)
|
||||
pub async fn set_volume(&mut self, volume: f32) -> Result<(), DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
|
||||
let rc_url = self.rendering_control_url.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);
|
||||
|
||||
let soap_body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:SetVolume xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
|
||||
<InstanceID>0</InstanceID>
|
||||
<Channel>Master</Channel>
|
||||
<DesiredVolume>{}</DesiredVolume>
|
||||
</u:SetVolume>
|
||||
</s:Body>
|
||||
</s:Envelope>"#,
|
||||
dlna_volume
|
||||
);
|
||||
|
||||
self.send_soap_action(
|
||||
rc_url,
|
||||
"urn:schemas-upnp-org:service:RenderingControl:1#SetVolume",
|
||||
&soap_body,
|
||||
).await?;
|
||||
|
||||
log::info!("DLNA: Set volume to {}", dlna_volume);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a SOAP action request
|
||||
async fn send_soap_action(
|
||||
&self,
|
||||
url: &str,
|
||||
action: &str,
|
||||
body: &str,
|
||||
) -> Result<String, DlnaError> {
|
||||
let response = self.client
|
||||
.post(url)
|
||||
.header("Content-Type", "text/xml; charset=utf-8")
|
||||
.header("SOAPAction", format!("\"{}\"", action))
|
||||
.body(body.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DlnaError::Playback(format!("SOAP request failed: {}", e)))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await
|
||||
.map_err(|e| DlnaError::Playback(format!("Failed to read response: {}", e)))?;
|
||||
|
||||
if !status.is_success() {
|
||||
log::error!("DLNA SOAP error ({}): {}", status, body);
|
||||
return Err(DlnaError::Playback(format!("SOAP error ({}): {}", status, parse_soap_error(&body))));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build DIDL-Lite metadata for a track
|
||||
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> 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)))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
<item id="0" parentID="-1" restricted="1">
|
||||
<dc:title>{}</dc:title>
|
||||
<dc:creator>{}</dc:creator>
|
||||
<upnp:album>{}</upnp:album>
|
||||
<upnp:artist>{}</upnp:artist>
|
||||
{}
|
||||
<res duration="{}" protocolInfo="http-get:*:audio/flac:*">{}</res>
|
||||
<upnp:class>object.item.audioItem.musicTrack</upnp:class>
|
||||
</item>
|
||||
</DIDL-Lite>"#,
|
||||
xml_escape(&metadata.title),
|
||||
xml_escape(&metadata.artist),
|
||||
xml_escape(&metadata.album),
|
||||
xml_escape(&metadata.artist),
|
||||
artwork,
|
||||
duration,
|
||||
xml_escape(uri)
|
||||
)
|
||||
}
|
||||
|
||||
/// Escape special XML characters
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Parse error message from SOAP fault response
|
||||
fn parse_soap_error(body: &str) -> String {
|
||||
// Try to extract faultstring or errorDescription
|
||||
if let Some(start) = body.find("<faultstring>") {
|
||||
if let Some(end) = body.find("</faultstring>") {
|
||||
return body[start + 13..end].to_string();
|
||||
}
|
||||
}
|
||||
if let Some(start) = body.find("<errorDescription>") {
|
||||
if let Some(end) = body.find("</errorDescription>") {
|
||||
return body[start + 18..end].to_string();
|
||||
}
|
||||
}
|
||||
"Unknown error".to_string()
|
||||
}
|
||||
@@ -108,9 +108,10 @@ pub fn run() {
|
||||
.expect("Failed to initialize library database");
|
||||
|
||||
// Initialize casting state (Chromecast, DLNA, AirPlay)
|
||||
// CastState creates the media server, DLNA shares it
|
||||
let cast_state = cast::CastState::new()
|
||||
.expect("Failed to initialize Chromecast state");
|
||||
let dlna_state = cast::dlna::commands::DlnaState::new()
|
||||
let dlna_state = cast::dlna::commands::DlnaState::new(cast_state.media_server.clone())
|
||||
.expect("Failed to initialize DLNA state");
|
||||
let airplay_state = cast::airplay::commands::AirPlayState::new()
|
||||
.expect("Failed to initialize AirPlay state");
|
||||
@@ -361,10 +362,12 @@ pub fn run() {
|
||||
cast::dlna::commands::dlna_connect,
|
||||
cast::dlna::commands::dlna_disconnect,
|
||||
cast::dlna::commands::dlna_get_status,
|
||||
cast::dlna::commands::dlna_play_track,
|
||||
cast::dlna::commands::dlna_load_media,
|
||||
cast::dlna::commands::dlna_play,
|
||||
cast::dlna::commands::dlna_pause,
|
||||
cast::dlna::commands::dlna_stop,
|
||||
cast::dlna::commands::dlna_seek,
|
||||
cast::dlna::commands::dlna_set_volume,
|
||||
// AirPlay casting commands
|
||||
cast::airplay::commands::airplay_start_discovery,
|
||||
|
||||
Reference in new issue
Block a user