implemente pmoparadise

This commit is contained in:
2025-10-12 19:52:41 +02:00
parent b8154a4837
commit 664be97ea6
28 changed files with 5266 additions and 5 deletions

View File

@@ -0,0 +1,167 @@
//! ConnectionManager service implementation
use pmoupnp::services::Service;
use pmoupnp::actions::Action;
use pmoupnp::state_variables::StateVariable;
use std::sync::Arc;
/// Create a ConnectionManager service
///
/// The ConnectionManager service provides information about supported
/// protocols and connections.
pub fn create_connection_manager_service() -> Service {
let mut service = Service::new("ConnectionManager".to_string());
service.set_service_type("urn:schemas-upnp-org:service:ConnectionManager:1".to_string());
service.set_service_id("urn:upnp-org:serviceId:ConnectionManager".to_string());
// State variables
let source_protocol_info = StateVariable::new(
"SourceProtocolInfo".to_string(),
"string".to_string(),
).with_send_events(true)
.with_default_value(get_protocol_info());
let sink_protocol_info = StateVariable::new(
"SinkProtocolInfo".to_string(),
"string".to_string(),
).with_send_events(true)
.with_default_value("".to_string());
let current_connection_ids = StateVariable::new(
"CurrentConnectionIDs".to_string(),
"string".to_string(),
).with_send_events(true)
.with_default_value("0".to_string());
service.add_state_variable(Arc::new(source_protocol_info));
service.add_state_variable(Arc::new(sink_protocol_info));
service.add_state_variable(Arc::new(current_connection_ids));
// GetProtocolInfo action
let mut get_protocol_info = Action::new("GetProtocolInfo".to_string());
get_protocol_info.add_output_argument(
"Source".to_string(),
"SourceProtocolInfo".to_string(),
);
get_protocol_info.add_output_argument(
"Sink".to_string(),
"SinkProtocolInfo".to_string(),
);
service.add_action(Arc::new(get_protocol_info));
// GetCurrentConnectionIDs action
let mut get_connection_ids = Action::new("GetCurrentConnectionIDs".to_string());
get_connection_ids.add_output_argument(
"ConnectionIDs".to_string(),
"CurrentConnectionIDs".to_string(),
);
service.add_action(Arc::new(get_connection_ids));
// GetCurrentConnectionInfo action
let mut get_connection_info = Action::new("GetCurrentConnectionInfo".to_string());
get_connection_info.add_input_argument(
"ConnectionID".to_string(),
"A_ARG_TYPE_ConnectionID".to_string(),
);
get_connection_info.add_output_argument(
"RcsID".to_string(),
"A_ARG_TYPE_RcsID".to_string(),
);
get_connection_info.add_output_argument(
"AVTransportID".to_string(),
"A_ARG_TYPE_AVTransportID".to_string(),
);
get_connection_info.add_output_argument(
"ProtocolInfo".to_string(),
"A_ARG_TYPE_ProtocolInfo".to_string(),
);
get_connection_info.add_output_argument(
"PeerConnectionManager".to_string(),
"A_ARG_TYPE_ConnectionManager".to_string(),
);
get_connection_info.add_output_argument(
"PeerConnectionID".to_string(),
"A_ARG_TYPE_ConnectionID".to_string(),
);
get_connection_info.add_output_argument(
"Direction".to_string(),
"A_ARG_TYPE_Direction".to_string(),
);
get_connection_info.add_output_argument(
"Status".to_string(),
"A_ARG_TYPE_ConnectionStatus".to_string(),
);
service.add_action(Arc::new(get_connection_info));
// Additional state variables for arguments
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ConnectionID".to_string(), "i4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_RcsID".to_string(), "i4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_AVTransportID".to_string(), "i4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ProtocolInfo".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ConnectionManager".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Direction".to_string(), "string".to_string())
.with_allowed_values(vec!["Input".to_string(), "Output".to_string()])
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ConnectionStatus".to_string(), "string".to_string())
.with_allowed_values(vec![
"OK".to_string(),
"ContentFormatMismatch".to_string(),
"InsufficientBandwidth".to_string(),
"UnreliableChannel".to_string(),
"Unknown".to_string(),
])
));
service
}
/// Get the protocol info string
///
/// Lists all supported protocols for Radio Paradise streaming.
fn get_protocol_info() -> String {
vec![
// HTTP FLAC
"http-get:*:audio/flac:*",
"http-get:*:audio/x-flac:*",
// HTTP AAC
"http-get:*:audio/aac:*",
"http-get:*:audio/aacp:*",
"http-get:*:audio/x-aac:*",
// HTTP MP3
"http-get:*:audio/mpeg:*",
"http-get:*:audio/mp3:*",
"http-get:*:audio/x-mp3:*",
].join(",")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_connection_manager() {
let service = create_connection_manager_service();
assert_eq!(service.service_type(), "urn:schemas-upnp-org:service:ConnectionManager:1");
assert_eq!(service.service_id(), "urn:upnp-org:serviceId:ConnectionManager");
}
#[test]
fn test_protocol_info() {
let info = get_protocol_info();
assert!(info.contains("audio/flac"));
assert!(info.contains("audio/aac"));
assert!(info.contains("audio/mpeg"));
}
}

View File

@@ -0,0 +1,330 @@
//! ContentDirectory service implementation
use crate::RadioParadiseClient;
use pmoupnp::services::Service;
use pmoupnp::actions::Action;
use pmoupnp::state_variables::StateVariable;
use pmodidl::{DIDLObject, DIDLContainer, DIDLItem, Resource};
use std::sync::Arc;
use tokio::sync::RwLock;
/// Create a ContentDirectory service for Radio Paradise
///
/// The ContentDirectory service allows browsing Radio Paradise blocks and songs.
pub fn create_content_directory_service(
client: Arc<RwLock<RadioParadiseClient>>,
) -> Service {
let mut service = Service::new("ContentDirectory".to_string());
service.set_service_type("urn:schemas-upnp-org:service:ContentDirectory:1".to_string());
service.set_service_id("urn:upnp-org:serviceId:ContentDirectory".to_string());
// State variables
let system_update_id = StateVariable::new(
"SystemUpdateID".to_string(),
"ui4".to_string(),
).with_send_events(true)
.with_default_value("0".to_string());
let container_update_ids = StateVariable::new(
"ContainerUpdateIDs".to_string(),
"string".to_string(),
).with_send_events(true)
.with_default_value("".to_string());
service.add_state_variable(Arc::new(system_update_id));
service.add_state_variable(Arc::new(container_update_ids));
// Browse action
let mut browse = Action::new("Browse".to_string());
browse.add_input_argument("ObjectID".to_string(), "A_ARG_TYPE_ObjectID".to_string());
browse.add_input_argument("BrowseFlag".to_string(), "A_ARG_TYPE_BrowseFlag".to_string());
browse.add_input_argument("Filter".to_string(), "A_ARG_TYPE_Filter".to_string());
browse.add_input_argument("StartingIndex".to_string(), "A_ARG_TYPE_Index".to_string());
browse.add_input_argument("RequestedCount".to_string(), "A_ARG_TYPE_Count".to_string());
browse.add_input_argument("SortCriteria".to_string(), "A_ARG_TYPE_SortCriteria".to_string());
browse.add_output_argument("Result".to_string(), "A_ARG_TYPE_Result".to_string());
browse.add_output_argument("NumberReturned".to_string(), "A_ARG_TYPE_Count".to_string());
browse.add_output_argument("TotalMatches".to_string(), "A_ARG_TYPE_Count".to_string());
browse.add_output_argument("UpdateID".to_string(), "A_ARG_TYPE_UpdateID".to_string());
// Store client reference for the action handler
let client_clone = client.clone();
browse.set_handler(Box::new(move |args| {
let client = client_clone.clone();
Box::pin(async move {
handle_browse(client, args).await
})
}));
service.add_action(Arc::new(browse));
// GetSearchCapabilities action
let mut get_search_caps = Action::new("GetSearchCapabilities".to_string());
get_search_caps.add_output_argument(
"SearchCaps".to_string(),
"A_ARG_TYPE_SearchCaps".to_string(),
);
get_search_caps.set_handler(Box::new(|_| {
Box::pin(async {
let mut result = std::collections::HashMap::new();
result.insert("SearchCaps".to_string(), "".to_string());
Ok(result)
})
}));
service.add_action(Arc::new(get_search_caps));
// GetSortCapabilities action
let mut get_sort_caps = Action::new("GetSortCapabilities".to_string());
get_sort_caps.add_output_argument(
"SortCaps".to_string(),
"A_ARG_TYPE_SortCaps".to_string(),
);
get_sort_caps.set_handler(Box::new(|_| {
Box::pin(async {
let mut result = std::collections::HashMap::new();
result.insert("SortCaps".to_string(), "dc:title".to_string());
Ok(result)
})
}));
service.add_action(Arc::new(get_sort_caps));
// GetSystemUpdateID action
let mut get_update_id = Action::new("GetSystemUpdateID".to_string());
get_update_id.add_output_argument("Id".to_string(), "SystemUpdateID".to_string());
get_update_id.set_handler(Box::new(|_| {
Box::pin(async {
let mut result = std::collections::HashMap::new();
result.insert("Id".to_string(), "0".to_string());
Ok(result)
})
}));
service.add_action(Arc::new(get_update_id));
// Argument state variables
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ObjectID".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_BrowseFlag".to_string(), "string".to_string())
.with_allowed_values(vec![
"BrowseMetadata".to_string(),
"BrowseDirectChildren".to_string(),
])
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Filter".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Index".to_string(), "ui4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Count".to_string(), "ui4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_SortCriteria".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Result".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_UpdateID".to_string(), "ui4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_SearchCaps".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_SortCaps".to_string(), "string".to_string())
));
service
}
/// Handle Browse action
async fn handle_browse(
client: Arc<RwLock<RadioParadiseClient>>,
args: std::collections::HashMap<String, String>,
) -> Result<std::collections::HashMap<String, String>, String> {
let object_id = args.get("ObjectID").ok_or("Missing ObjectID")?;
let browse_flag = args.get("BrowseFlag").ok_or("Missing BrowseFlag")?;
let starting_index: usize = args.get("StartingIndex")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let requested_count: usize = args.get("RequestedCount")
.and_then(|s| s.parse().ok())
.unwrap_or(100);
let client = client.read().await;
let (didl_result, number_returned, total_matches) = match object_id.as_str() {
"0" => {
// Root container - show current block
if browse_flag == "BrowseMetadata" {
let root = create_root_container();
(serialize_didl(&[root]), 1, 1)
} else {
// BrowseDirectChildren - show current block as a container
let block = client.get_block(None).await
.map_err(|e| format!("Failed to get block: {}", e))?;
let block_container = create_block_container(&block);
(serialize_didl(&[block_container]), 1, 1)
}
}
id if id.starts_with("block:") => {
// Browse songs in a block
let event_id: u64 = id.strip_prefix("block:")
.and_then(|s| s.parse().ok())
.ok_or("Invalid block ID")?;
let block = client.get_block(Some(event_id)).await
.map_err(|e| format!("Failed to get block: {}", e))?;
if browse_flag == "BrowseMetadata" {
let container = create_block_container(&block);
(serialize_didl(&[container]), 1, 1)
} else {
// BrowseDirectChildren - show songs
let songs = block.songs_ordered();
let total = songs.len();
let songs_slice = songs.iter()
.skip(starting_index)
.take(requested_count)
.collect::<Vec<_>>();
let items: Vec<DIDLObject> = songs_slice.iter()
.map(|(idx, song)| create_song_item(&block, *idx, song))
.collect();
(serialize_didl(&items), items.len(), total)
}
}
_ => {
return Err(format!("Unknown ObjectID: {}", object_id));
}
};
let mut result = std::collections::HashMap::new();
result.insert("Result".to_string(), didl_result);
result.insert("NumberReturned".to_string(), number_returned.to_string());
result.insert("TotalMatches".to_string(), total_matches.to_string());
result.insert("UpdateID".to_string(), "0".to_string());
Ok(result)
}
/// Create the root container
fn create_root_container() -> DIDLObject {
let mut container = DIDLContainer::new("0".to_string(), "-1".to_string());
container.set_title("Radio Paradise".to_string());
container.set_class("object.container.storageFolder".to_string());
container.set_searchable(false);
container.set_child_count(Some(1));
DIDLObject::Container(container)
}
/// Create a container for a block
fn create_block_container(block: &crate::models::Block) -> DIDLObject {
let mut container = DIDLContainer::new(
format!("block:{}", block.event),
"0".to_string(),
);
container.set_title(format!("Block {} ({} songs)", block.event, block.song_count()));
container.set_class("object.container.album.musicAlbum".to_string());
container.set_searchable(false);
container.set_child_count(Some(block.song_count()));
// Add album art if available
if let Some(first_song) = block.get_song(0) {
if let Some(cover) = &first_song.cover {
if let Some(cover_url) = block.cover_url(cover) {
container.add_album_art_uri(cover_url);
}
}
}
DIDLObject::Container(container)
}
/// Create an item for a song
fn create_song_item(
block: &crate::models::Block,
index: usize,
song: &crate::models::Song,
) -> DIDLObject {
let mut item = DIDLItem::new(
format!("block:{}:song:{}", block.event, index),
format!("block:{}", block.event),
);
item.set_title(song.title.clone());
item.set_class("object.item.audioItem.musicTrack".to_string());
// Add metadata
item.add_artist(song.artist.clone());
item.add_album(song.album.clone());
if let Some(year) = song.year {
item.set_date(format!("{}-01-01", year));
}
// Add album art
if let Some(cover) = &song.cover {
if let Some(cover_url) = block.cover_url(cover) {
item.add_album_art_uri(cover_url);
}
}
// Add resource for streaming
let mut resource = Resource::new(block.url.clone());
resource.set_protocol_info("http-get:*:audio/flac:*".to_string());
resource.set_duration(format_duration(song.duration));
resource.set_size(None); // Unknown size
item.add_resource(resource);
DIDLObject::Item(item)
}
/// Format duration in H:MM:SS format
fn format_duration(duration_ms: u64) -> String {
let total_seconds = duration_ms / 1000;
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!("{}:{:02}:{:02}", hours, minutes, seconds)
}
/// Serialize DIDL objects to XML string
fn serialize_didl(objects: &[DIDLObject]) -> String {
let mut didl = String::from(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/">"#);
for obj in objects {
didl.push_str(&obj.to_didl());
}
didl.push_str("</DIDL-Lite>");
didl
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_duration() {
assert_eq!(format_duration(0), "0:00:00");
assert_eq!(format_duration(60000), "0:01:00");
assert_eq!(format_duration(3661000), "1:01:01");
}
#[test]
fn test_create_root_container() {
let root = create_root_container();
if let DIDLObject::Container(container) = root {
assert_eq!(container.id(), "0");
assert_eq!(container.parent_id(), "-1");
} else {
panic!("Expected Container");
}
}
}

View File

@@ -0,0 +1,58 @@
//! UPnP Media Server for Radio Paradise
//!
//! This module provides a UPnP/DLNA Media Server implementation that exposes
//! Radio Paradise blocks and songs as a browsable media library.
//!
//! # Features
//!
//! - ContentDirectory service for browsing blocks and songs
//! - ConnectionManager service for protocol info
//! - DIDL-Lite metadata for songs
//! - Support for multiple quality levels
//! - Live streaming URLs
//!
//! # Architecture
//!
//! ```text
//! RadioParadiseMediaServer
//! └── Device (urn:schemas-upnp-org:device:MediaServer:1)
//! ├── ContentDirectory service
//! │ ├── Browse action
//! │ ├── Search action (optional)
//! │ └── GetSearchCapabilities
//! └── ConnectionManager service
//! ├── GetProtocolInfo
//! └── GetCurrentConnectionIDs
//! ```
//!
//! # Example
//!
//! ```no_run
//! # #[cfg(feature = "mediaserver")]
//! # {
//! use pmoparadise::mediaserver::RadioParadiseMediaServer;
//! use pmoparadise::Bitrate;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let server = RadioParadiseMediaServer::new()
//! .with_bitrate(Bitrate::Flac)
//! .with_friendly_name("Radio Paradise FLAC")
//! .build()
//! .await?;
//!
//! server.run().await?;
//! Ok(())
//! }
//! # }
//! ```
#[cfg(feature = "mediaserver")]
mod server;
#[cfg(feature = "mediaserver")]
mod content_directory;
#[cfg(feature = "mediaserver")]
mod connection_manager;
#[cfg(feature = "mediaserver")]
pub use server::{RadioParadiseMediaServer, MediaServerBuilder};

View File

@@ -0,0 +1,197 @@
//! Radio Paradise UPnP Media Server implementation
use crate::error::{Error, Result};
use crate::models::Bitrate;
use crate::RadioParadiseClient;
use pmoupnp::devices::Device;
use pmoupnp::{UpnpServer};
use pmoserver::Server;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Radio Paradise UPnP Media Server
///
/// Exposes Radio Paradise blocks and songs as a browsable UPnP media library.
pub struct RadioParadiseMediaServer {
server: Server,
client: Arc<RwLock<RadioParadiseClient>>,
device_udn: String,
}
impl RadioParadiseMediaServer {
/// Create a new builder for the media server
pub fn builder() -> MediaServerBuilder {
MediaServerBuilder::default()
}
/// Create a new media server with default settings
pub async fn new() -> Result<Self> {
Self::builder().build().await
}
/// Run the media server
///
/// This will start the HTTP server and SSDP announcements.
pub async fn run(self) -> Result<()> {
self.server.run().await
.map_err(|e| Error::other(format!("Server error: {}", e)))
}
/// Get the device UDN
pub fn udn(&self) -> &str {
&self.device_udn
}
/// Get the Radio Paradise client
pub fn client(&self) -> Arc<RwLock<RadioParadiseClient>> {
self.client.clone()
}
}
/// Builder for RadioParadiseMediaServer
pub struct MediaServerBuilder {
friendly_name: String,
manufacturer: String,
model_name: String,
bitrate: Bitrate,
channel: u8,
port: u16,
}
impl Default for MediaServerBuilder {
fn default() -> Self {
Self {
friendly_name: "Radio Paradise Media Server".to_string(),
manufacturer: "PMOMusic".to_string(),
model_name: "Radio Paradise Adapter".to_string(),
bitrate: Bitrate::Flac,
channel: 0,
port: 8080,
}
}
}
impl MediaServerBuilder {
/// Create a new builder with default settings
pub fn new() -> Self {
Self::default()
}
/// Set the friendly name for the device
pub fn with_friendly_name(mut self, name: impl Into<String>) -> Self {
self.friendly_name = name.into();
self
}
/// Set the manufacturer name
pub fn with_manufacturer(mut self, name: impl Into<String>) -> Self {
self.manufacturer = name.into();
self
}
/// Set the model name
pub fn with_model_name(mut self, name: impl Into<String>) -> Self {
self.model_name = name.into();
self
}
/// Set the bitrate/quality level
pub fn with_bitrate(mut self, bitrate: Bitrate) -> Self {
self.bitrate = bitrate;
self
}
/// Set the Radio Paradise channel (0=main, 1=mellow, 2=rock, 3=world)
pub fn with_channel(mut self, channel: u8) -> Self {
self.channel = channel;
self
}
/// Set the HTTP server port
pub fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
/// Build the media server
pub async fn build(self) -> Result<RadioParadiseMediaServer> {
// Create Radio Paradise client
let client = RadioParadiseClient::builder()
.bitrate(self.bitrate)
.channel(self.channel)
.build()
.await?;
let client = Arc::new(RwLock::new(client));
// Create HTTP server
let mut server = pmoserver::ServerBuilder::new()
.with_port(self.port)
.build()
.map_err(|e| Error::other(format!("Failed to create server: {}", e)))?;
// Create UPnP device
let device_udn = format!("uuid:{}", uuid::Uuid::new_v4());
let mut device = Device::new(
"MediaServer".to_string(),
"MediaServer".to_string(),
self.friendly_name.clone(),
);
device.set_manufacturer(self.manufacturer);
device.set_model_name(self.model_name);
device.set_udn(device_udn.clone());
// Add ContentDirectory service
let content_directory = super::content_directory::create_content_directory_service(
client.clone()
);
device.add_service(Arc::new(content_directory))
.map_err(|e| Error::other(format!("Failed to add ContentDirectory: {:?}", e)))?;
// Add ConnectionManager service
let connection_manager = super::connection_manager::create_connection_manager_service();
device.add_service(Arc::new(connection_manager))
.map_err(|e| Error::other(format!("Failed to add ConnectionManager: {:?}", e)))?;
// Register device with server
server.register_device(Arc::new(device))
.await
.map_err(|e| Error::other(format!("Failed to register device: {:?}", e)))?;
Ok(RadioParadiseMediaServer {
server,
client,
device_udn,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_defaults() {
let builder = MediaServerBuilder::default();
assert_eq!(builder.friendly_name, "Radio Paradise Media Server");
assert_eq!(builder.bitrate, Bitrate::Flac);
assert_eq!(builder.channel, 0);
assert_eq!(builder.port, 8080);
}
#[test]
fn test_builder_customization() {
let builder = MediaServerBuilder::new()
.with_friendly_name("Custom Server")
.with_bitrate(Bitrate::Aac320)
.with_channel(1)
.with_port(9090);
assert_eq!(builder.friendly_name, "Custom Server");
assert_eq!(builder.bitrate, Bitrate::Aac320);
assert_eq!(builder.channel, 1);
assert_eq!(builder.port, 9090);
}
}