Skip to content

Commit

Permalink
feat: added media renderer controls
Browse files Browse the repository at this point in the history
  • Loading branch information
tsirysndr committed Feb 3, 2023
1 parent 1c252b5 commit d989052
Show file tree
Hide file tree
Showing 10 changed files with 793 additions and 166 deletions.
13 changes: 13 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ authors = ["Tsiry Sandratraina <[email protected]>"]
categories = ["command-line-utilities", "network-programming"]
keywords = ["upnp", "client", "tokio", "dlna"]
description = "A simple UPnP client written in Rust"

[[example]]
name = "discover"
path = "examples/discover.rs"

[[example]]
name = "media-renderer-client"
path = "examples/media_renderer_client.rs"


# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
Expand All @@ -23,3 +33,6 @@ serde_json = "1.0.91"
socket2 = "0.4.7"
surf = { version = "2.3.2", features = ["h1-client-rustls"], default-features = false}
tokio = { version = "1.24.2", features = ["tokio-macros", "macros", "rt", "rt-multi-thread"] }
url = "2.3.1"
xml-builder = "0.5.1"
xml-rs = "0.8.4"
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,72 @@ Output:
}
```

## Streaming

```rust
use futures_util::StreamExt;
use upnp_client::{
device_client::DeviceClient,
discovery::discover_pnp_locations,
media_renderer::MediaRendererClient,
types::{Device, LoadOptions, Metadata, ObjectClass},
};

const KODI_MEDIA_RENDERER: &str = "Kodi - Media Renderer";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let devices = discover_pnp_locations();
tokio::pin!(devices);

let mut kodi_device: Option<Device> = None;
while let Some(device) = devices.next().await {
// Select the first Kodi device found
if device.model_description == Some(KODI_MEDIA_RENDERER.to_string()) {
kodi_device = Some(device);
break;
}
}

let kodi_device = kodi_device.unwrap();
let device_client = DeviceClient::new(&kodi_device.location).connect().await?;
let media_renderer = MediaRendererClient::new(device_client);

let options = LoadOptions {
dlna_features: Some(
"DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000"
.to_string(),
),
content_type: Some("video/mp4".to_string()),
metadata: Some(Metadata {
title: "Big Buck Bunny".to_string(),
..Default::default()
}),
autoplay: true,
object_class: Some(ObjectClass::Video),
..Default::default()
};

let media_url =
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";

media_renderer.load(media_url, options).await?;

Ok(())
}


```

### Features

- [x] Discover devices
- [x] Control device (Load, Play, Pause, Stop, Seek, etc.)


### References
- [UPnP Device Architecture 1.1](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.1.pdf)
- [UPnP AVTransport v3 Service](http://www.upnp.org/specs/av/UPnP-av-AVTransport-v3-Service-20101231.pdf)

### License
MIT
6 changes: 1 addition & 5 deletions src/main.rs → examples/discover.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
use colored_json::prelude::*;
use futures_util::StreamExt;

use crate::discovery::discover_pnp_locations;

mod discovery;
mod types;
use upnp_client::discovery::discover_pnp_locations;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
Expand Down
50 changes: 50 additions & 0 deletions examples/media_renderer_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
use futures_util::StreamExt;
use upnp_client::{
device_client::DeviceClient,
discovery::discover_pnp_locations,
media_renderer::MediaRendererClient,
types::{Device, LoadOptions, Metadata, ObjectClass},
};

const KODI_MEDIA_RENDERER: &str = "Kodi - Media Renderer";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let devices = discover_pnp_locations();
tokio::pin!(devices);

let mut kodi_device: Option<Device> = None;
while let Some(device) = devices.next().await {
// Select the first Kodi device found
if device.model_description == Some(KODI_MEDIA_RENDERER.to_string()) {
kodi_device = Some(device);
break;
}
}

let kodi_device = kodi_device.unwrap();
let device_client = DeviceClient::new(&kodi_device.location).connect().await?;
let media_renderer = MediaRendererClient::new(device_client);

let options = LoadOptions {
dlna_features: Some(
"DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000"
.to_string(),
),
content_type: Some("video/mp4".to_string()),
metadata: Some(Metadata {
title: "Big Buck Bunny".to_string(),
..Default::default()
}),
autoplay: true,
object_class: Some(ObjectClass::Video),
..Default::default()
};

let media_url =
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";

media_renderer.load(media_url, options).await?;

Ok(())
}
126 changes: 112 additions & 14 deletions src/device_client.rs
Original file line number Diff line number Diff line change
@@ -1,31 +1,133 @@
use std::time::Duration;
use std::{collections::HashMap, time::Duration};

use surf::{Client, Config, Error, Url};
use anyhow::Error;
use surf::{Client, Config, Url};
use xml_builder::{XMLBuilder, XMLElement, XMLVersion};

use crate::{
parser::parse_location,
types::{Device, Service},
};

pub struct DeviceClient {
base_url: Url,
http_client: Client,
device: Option<Device>,
}

impl DeviceClient {
pub fn new() -> Self {
pub fn new(url: &str) -> Self {
Self {
base_url: Url::parse(url).unwrap(),
http_client: Config::new()
.set_timeout(Some(Duration::from_secs(5)))
.try_into()
.unwrap(),
device: None,
}
}

pub async fn call_action(&self, service_id: &str, action_name: &str) -> Result<(), Error> {
pub async fn connect(&mut self) -> Result<Self, Error> {
self.device = Some(parse_location(self.base_url.as_str()).await?);
Ok(Self {
base_url: self.base_url.clone(),
http_client: self.http_client.clone(),
device: self.device.clone(),
})
}

pub async fn call_action(
&self,
service_id: &str,
action_name: &str,
params: HashMap<String, String>,
) -> Result<String, Error> {
if self.device.is_none() {
return Err(Error::msg("Device not connected"));
}
let service_id = resolve_service(service_id);
self.get_service_description(&service_id).await;
let service_url = Url::parse("http://").unwrap();
self.http_client.post(service_url).send().await?;
Ok(())
let service = self.get_service_description(&service_id).await?;

// check if action is available
let action = service.actions.iter().find(|a| a.name == action_name);
match action {
Some(_) => {
self.call_action_internal(&service, action_name, params)
.await
}
None => Err(Error::msg("Action not found")),
}
}

async fn get_service_description(&self, service_id: &str) {
todo!()
async fn call_action_internal(
&self,
service: &Service,
action_name: &str,
params: HashMap<String, String>,
) -> Result<String, Error> {
let control_url = Url::parse(&service.control_url).unwrap();

let mut xml = XMLBuilder::new()
.version(XMLVersion::XML1_1)
.encoding("UTF-8".into())
.build();

let mut envelope = XMLElement::new("s:Envelope");
envelope.add_attribute("xmlns:s", "http://schemas.xmlsoap.org/soap/envelope/");
envelope.add_attribute(
"s:encodingStyle",
"http://schemas.xmlsoap.org/soap/encoding/",
);

let mut body = XMLElement::new("s:Body");
let action = format!("u:{}", action_name);
let mut action = XMLElement::new(action.as_str());
action.add_attribute("xmlns:u", service.service_type.as_str());

for (name, value) in params {
let mut param = XMLElement::new(name.as_str());
param.add_text(value).unwrap();
action.add_child(param).unwrap();
}

body.add_child(action).unwrap();
envelope.add_child(body).unwrap();

xml.set_root_element(envelope);

let mut writer: Vec<u8> = Vec::new();
xml.generate(&mut writer).unwrap();
let xml = String::from_utf8(writer).unwrap();

let soap_action = format!("\"{}#{}\"", service.service_type, action_name);

let mut res = self
.http_client
.post(control_url)
.header("Content-Type", "text/xml; charset=\"utf-8\"")
.header("Content-Length", xml.len().to_string())
.header("SOAPACTION", soap_action)
.header("Connection", "close")
.body_string(xml.clone())
.send()
.await
.map_err(|e| Error::msg(e.to_string()))?;
Ok(res
.body_string()
.await
.map_err(|e| Error::msg(e.to_string()))?)
}

async fn get_service_description(&self, service_id: &str) -> Result<Service, Error> {
if let Some(device) = &self.device {
let service = device
.services
.iter()
.find(|s| s.service_id == service_id)
.unwrap();
return Ok(service.clone());
}
Err(Error::msg("Device not connected"))
}
}

Expand All @@ -35,7 +137,3 @@ fn resolve_service(service_id: &str) -> String {
false => format!("urn:upnp-org:serviceId:{}", service_id),
}
}

fn parse_service_description(xml: &str) {
todo!()
}
Loading

0 comments on commit d989052

Please sign in to comment.