retire le mediaserver de pmoparadise
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -2504,11 +2504,9 @@ dependencies = [
|
||||
"pmoaudiocache",
|
||||
"pmoconfig",
|
||||
"pmocovers",
|
||||
"pmodidl",
|
||||
"pmoplaylist",
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
@@ -2525,7 +2523,6 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
|
||||
@@ -55,12 +55,6 @@ claxon = "0.4"
|
||||
hound = { version = "3.5", optional = true }
|
||||
tempfile = { version = "3.8", optional = true }
|
||||
|
||||
# UPnP Media Server dependencies
|
||||
pmoupnp = { path = "../pmoupnp", optional = true }
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
pmodidl = { path = "../pmodidl", optional = true }
|
||||
uuid = { version = "1.18", optional = true }
|
||||
|
||||
# Common music source traits
|
||||
pmosource = { path = "../pmosource" }
|
||||
|
||||
@@ -72,7 +66,8 @@ pmoconfig = { path = "../pmoconfig" }
|
||||
pmocovers = { path = "../pmocovers" }
|
||||
pmoaudiocache = { path = "../pmoaudiocache" }
|
||||
|
||||
# OpenAPI/Swagger support (pour pmoserver extension)
|
||||
# pmoserver extension support
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
axum = { version = "0.8.4", optional = true }
|
||||
|
||||
@@ -84,8 +79,6 @@ metadata-only = []
|
||||
per-track = ["dep:hound", "dep:tempfile"]
|
||||
# Active l'API REST pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
|
||||
# Active le media server UPnP (includes pmoserver)
|
||||
mediaserver = ["dep:pmoupnp", "dep:pmodidl", "dep:uuid", "pmoserver"]
|
||||
# Feature pour activer le support serveur (cache registry)
|
||||
server = ["pmosource/server"]
|
||||
# Feature cache (deprecated - toujours actif maintenant)
|
||||
@@ -114,11 +107,6 @@ name = "extract_track"
|
||||
path = "examples/extract_track.rs"
|
||||
required-features = ["per-track"]
|
||||
|
||||
[[example]]
|
||||
name = "upnp_mediaserver"
|
||||
path = "examples/upnp_mediaserver.rs"
|
||||
required-features = ["mediaserver"]
|
||||
|
||||
[[example]]
|
||||
name = "with_cache"
|
||||
path = "examples/with_cache.rs"
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//! Example: Run a UPnP/DLNA Media Server for Radio Paradise
|
||||
//!
|
||||
//! This example demonstrates:
|
||||
//! - Creating a UPnP Media Server
|
||||
//! - Exposing Radio Paradise blocks and songs
|
||||
//! - SSDP discovery and announcements
|
||||
//! - ContentDirectory and ConnectionManager services
|
||||
//!
|
||||
//! Run with: cargo run --example upnp_mediaserver --features mediaserver
|
||||
//!
|
||||
//! The server will be discoverable by DLNA/UPnP clients on your network.
|
||||
|
||||
#[cfg(feature = "mediaserver")]
|
||||
use pmoparadise::mediaserver::RadioParadiseMediaServer;
|
||||
#[cfg(feature = "mediaserver")]
|
||||
use pmoparadise::Bitrate;
|
||||
|
||||
#[cfg(feature = "mediaserver")]
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize logging
|
||||
#[cfg(feature = "logging")]
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
println!("Radio Paradise UPnP Media Server");
|
||||
println!("=================================\n");
|
||||
|
||||
// Create the media server
|
||||
println!("Creating media server...");
|
||||
let server = RadioParadiseMediaServer::builder()
|
||||
.with_friendly_name("Radio Paradise FLAC")
|
||||
.with_manufacturer("PMOMusic")
|
||||
.with_model_name("Radio Paradise Adapter v0.1")
|
||||
.with_bitrate(Bitrate::Flac)
|
||||
.with_channel(0) // Main mix
|
||||
.with_port(8080)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
println!("Media Server created!");
|
||||
println!(" UDN: {}", server.udn());
|
||||
println!(" Port: 8080");
|
||||
println!(" Quality: FLAC Lossless");
|
||||
println!(" Channel: Main Mix (0)");
|
||||
println!();
|
||||
|
||||
println!("Server is now discoverable on your network.");
|
||||
println!("Look for 'Radio Paradise FLAC' in your DLNA/UPnP clients.");
|
||||
println!();
|
||||
println!("ContentDirectory service available at:");
|
||||
println!(
|
||||
" http://localhost:8080/upnp/device/{}/service/ContentDirectory",
|
||||
server.udn()
|
||||
);
|
||||
println!();
|
||||
println!("Press Ctrl+C to stop the server.");
|
||||
println!();
|
||||
|
||||
// Run the server
|
||||
server.run().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mediaserver"))]
|
||||
fn main() {
|
||||
eprintln!("ERROR: This example requires the 'mediaserver' feature.");
|
||||
eprintln!("Run with: cargo run --example upnp_mediaserver --features mediaserver");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -209,9 +209,9 @@
|
||||
//!
|
||||
//! - `default = ["metadata-only"]`: Standard metadata and streaming (no FLAC decoding)
|
||||
//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`)
|
||||
//! - `logging`: Enable tracing logs for debugging
|
||||
//! - `mediaserver`: Enable UPnP/DLNA Media Server (adds `pmoupnp`, `pmoserver`, `pmodidl`)
|
||||
//! - `cache`: Enable cover and audio caching support (adds `pmocovers`, `pmoaudiocache`, enables `logging`)
|
||||
//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`)
|
||||
//! - `server`: Enable server-side features (cache registry integration)
|
||||
//! - `cache`: Enable cover and audio caching support (adds `pmocovers`, `pmoaudiocache`)
|
||||
//!
|
||||
//! ## See Also
|
||||
//!
|
||||
@@ -229,9 +229,6 @@ pub mod streaming;
|
||||
#[cfg(feature = "per-track")]
|
||||
pub mod track;
|
||||
|
||||
#[cfg(feature = "mediaserver")]
|
||||
pub mod mediaserver;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod pmoserver_ext;
|
||||
|
||||
@@ -245,9 +242,6 @@ pub use stream::BlockStream;
|
||||
#[cfg(feature = "per-track")]
|
||||
pub use track::{TrackMetadata, TrackStream};
|
||||
|
||||
#[cfg(feature = "mediaserver")]
|
||||
pub use mediaserver::{MediaServerBuilder, RadioParadiseMediaServer};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub use pmoserver_ext::{
|
||||
create_api_router, RadioParadiseApiDoc, RadioParadiseExt, RadioParadiseState,
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
//! ConnectionManager service implementation
|
||||
|
||||
use pmoupnp::actions::Action;
|
||||
use pmoupnp::services::Service;
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
//! ContentDirectory service implementation
|
||||
|
||||
use crate::RadioParadiseClient;
|
||||
use pmodidl::{DIDLContainer, DIDLItem, DIDLObject, Resource};
|
||||
use pmoupnp::actions::Action;
|
||||
use pmoupnp::services::Service;
|
||||
use pmoupnp::state_variables::StateVariable;
|
||||
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());
|
||||
if let Some(ref album) = song.album {
|
||||
item.add_album(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//! 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;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let server = RadioParadiseMediaServer::builder()
|
||||
//! .with_friendly_name("Radio Paradise FLAC")
|
||||
//! .build()
|
||||
//! .await?;
|
||||
//!
|
||||
//! server.run().await?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "mediaserver")]
|
||||
mod connection_manager;
|
||||
#[cfg(feature = "mediaserver")]
|
||||
mod content_directory;
|
||||
#[cfg(feature = "mediaserver")]
|
||||
mod server;
|
||||
|
||||
#[cfg(feature = "mediaserver")]
|
||||
pub use server::{MediaServerBuilder, RadioParadiseMediaServer};
|
||||
@@ -1,188 +0,0 @@
|
||||
//! Radio Paradise UPnP Media Server implementation
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::RadioParadiseClient;
|
||||
use pmoserver::Server;
|
||||
use pmoupnp::devices::Device;
|
||||
use pmoupnp::UpnpServer;
|
||||
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,
|
||||
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(),
|
||||
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 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()
|
||||
.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.channel, 0);
|
||||
assert_eq!(builder.port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_customization() {
|
||||
let builder = MediaServerBuilder::new()
|
||||
.with_friendly_name("Custom Server")
|
||||
.with_channel(1)
|
||||
.with_port(9090);
|
||||
|
||||
assert_eq!(builder.friendly_name, "Custom Server");
|
||||
assert_eq!(builder.channel, 1);
|
||||
assert_eq!(builder.port, 9090);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user