Merge pull request #226 from ikatson/connection-manager-stub
[Feature] [UPnP] connection manager stub
This commit is contained in:
commit
d7f3d883f2
17 changed files with 810 additions and 541 deletions
2
Makefile
2
Makefile
|
|
@ -14,7 +14,7 @@ webui-dev: webui-deps
|
||||||
export RQBIT_UPNP_SERVER_ENABLE ?= true
|
export RQBIT_UPNP_SERVER_ENABLE ?= true
|
||||||
export RQBIT_UPNP_SERVER_FRIENDLY_NAME ?= rqbit-dev
|
export RQBIT_UPNP_SERVER_FRIENDLY_NAME ?= rqbit-dev
|
||||||
export RQBIT_HTTP_API_LISTEN_ADDR ?= 0.0.0.0:3030
|
export RQBIT_HTTP_API_LISTEN_ADDR ?= 0.0.0.0:3030
|
||||||
CARGO_RUN_FLAGS ?= ""
|
CARGO_RUN_FLAGS ?=
|
||||||
RQBIT_OUTPUT_FOLDER ?= /tmp/scratch
|
RQBIT_OUTPUT_FOLDER ?= /tmp/scratch
|
||||||
RQBIT_POSTGRES_CONNECTION_STRING ?= postgres:///rqbit
|
RQBIT_POSTGRES_CONNECTION_STRING ?= postgres:///rqbit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ use itertools::Itertools;
|
||||||
use librqbit_core::torrent_metainfo::TorrentMetaV1Info;
|
use librqbit_core::torrent_metainfo::TorrentMetaV1Info;
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
use upnp_serve::{
|
use upnp_serve::{
|
||||||
upnp_types::content_directory::{
|
services::content_directory::{
|
||||||
response::{Container, Item, ItemOrContainer},
|
browse::response::{Container, Item, ItemOrContainer},
|
||||||
ContentDirectoryBrowseProvider,
|
ContentDirectoryBrowseProvider,
|
||||||
},
|
},
|
||||||
UpnpServer, UpnpServerOptions,
|
UpnpServer, UpnpServerOptions,
|
||||||
|
|
@ -346,8 +346,8 @@ mod tests {
|
||||||
TorrentMetaV1File, TorrentMetaV1Info, TorrentMetaV1Owned,
|
TorrentMetaV1File, TorrentMetaV1Info, TorrentMetaV1Owned,
|
||||||
};
|
};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use upnp_serve::upnp_types::content_directory::{
|
use upnp_serve::services::content_directory::{
|
||||||
response::{Container, Item, ItemOrContainer},
|
browse::response::{Container, Item, ItemOrContainer},
|
||||||
ContentDirectoryBrowseProvider,
|
ContentDirectoryBrowseProvider,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use std::{
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use librqbit_upnp_serve::{
|
use librqbit_upnp_serve::{
|
||||||
upnp_types::content_directory::response::{Item, ItemOrContainer},
|
services::content_directory::browse::response::{Item, ItemOrContainer},
|
||||||
UpnpServer, UpnpServerOptions,
|
UpnpServer, UpnpServerOptions,
|
||||||
};
|
};
|
||||||
use mime_guess::Mime;
|
use mime_guess::Mime;
|
||||||
|
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
use std::{sync::atomic::Ordering, time::Duration};
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use axum::{
|
|
||||||
body::Bytes,
|
|
||||||
extract::State,
|
|
||||||
handler::HandlerWithoutStateExt,
|
|
||||||
response::IntoResponse,
|
|
||||||
routing::{get, post},
|
|
||||||
};
|
|
||||||
use bstr::BStr;
|
|
||||||
use http::{header::CONTENT_TYPE, HeaderMap, HeaderName, StatusCode};
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
use tracing::{debug, trace, warn};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
constants::{
|
|
||||||
CONTENT_TYPE_XML_UTF8, SOAP_ACTION_CONTENT_DIRECTORY_BROWSE,
|
|
||||||
SOAP_ACTION_GET_SYSTEM_UPDATE_ID,
|
|
||||||
},
|
|
||||||
state::{UnpnServerState, UpnpServerStateInner},
|
|
||||||
templates::{
|
|
||||||
render_content_directory_browse, render_content_directory_control_get_system_update_id,
|
|
||||||
render_root_description_xml, RootDescriptionInputs,
|
|
||||||
},
|
|
||||||
upnp_types::content_directory::{
|
|
||||||
request::ContentDirectoryControlRequest, ContentDirectoryBrowseProvider,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
async fn description_xml(State(state): State<UnpnServerState>) -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
|
||||||
state.rendered_root_description.clone(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_content_directory_control_response(
|
|
||||||
headers: HeaderMap,
|
|
||||||
State(state): State<UnpnServerState>,
|
|
||||||
body: Bytes,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let body = BStr::new(&body);
|
|
||||||
let action = headers.get("soapaction").map(|v| BStr::new(v.as_bytes()));
|
|
||||||
trace!(?body, ?action, "received control request");
|
|
||||||
let action = match action {
|
|
||||||
Some(action) => action,
|
|
||||||
None => {
|
|
||||||
debug!("missing SOAPACTION header");
|
|
||||||
return (StatusCode::BAD_REQUEST, "").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match action.as_ref() {
|
|
||||||
SOAP_ACTION_CONTENT_DIRECTORY_BROWSE => {
|
|
||||||
let http_hostname = headers
|
|
||||||
.get("host")
|
|
||||||
.and_then(|h| std::str::from_utf8(h.as_bytes()).ok())
|
|
||||||
.and_then(|h| h.split(':').next());
|
|
||||||
let http_hostname = match http_hostname {
|
|
||||||
Some(h) => h,
|
|
||||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = match std::str::from_utf8(body) {
|
|
||||||
Ok(body) => body,
|
|
||||||
Err(_) => return (StatusCode::BAD_REQUEST, "cannot parse request").into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let request = match ContentDirectoryControlRequest::parse(body) {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(e) => {
|
|
||||||
debug!(error=?e, "error parsing XML");
|
|
||||||
return (StatusCode::BAD_REQUEST, "cannot parse request").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::upnp_types::content_directory::request::BrowseFlag;
|
|
||||||
|
|
||||||
match request.browse_flag {
|
|
||||||
BrowseFlag::BrowseDirectChildren => (
|
|
||||||
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
|
||||||
render_content_directory_browse(
|
|
||||||
state
|
|
||||||
.provider
|
|
||||||
.browse_direct_children(request.object_id, http_hostname),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
BrowseFlag::BrowseMetadata => StatusCode::NOT_IMPLEMENTED.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SOAP_ACTION_GET_SYSTEM_UPDATE_ID => {
|
|
||||||
let update_id = state.system_update_id.load(Ordering::Relaxed);
|
|
||||||
(
|
|
||||||
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
|
||||||
render_content_directory_control_get_system_update_id(update_id),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
debug!(?action, "unsupported ContentDirectory action");
|
|
||||||
(StatusCode::NOT_IMPLEMENTED, "").into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn subscription(
|
|
||||||
State(state): State<UnpnServerState>,
|
|
||||||
request: axum::extract::Request,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
if request.method().as_str() != "SUBSCRIBE" {
|
|
||||||
return (StatusCode::METHOD_NOT_ALLOWED, "").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (parts, _body) = request.into_parts();
|
|
||||||
let is_event = parts
|
|
||||||
.headers
|
|
||||||
.get(HeaderName::from_static("nt"))
|
|
||||||
.map(|v| v.as_bytes() == b"upnp:event")
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !is_event {
|
|
||||||
return (StatusCode::BAD_REQUEST, "expected NT: upnp:event header").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let callback = parts
|
|
||||||
.headers
|
|
||||||
.get(HeaderName::from_static("callback"))
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.trim_matches(|c| c == '>' || c == '<'))
|
|
||||||
.and_then(|u| url::Url::parse(u).ok());
|
|
||||||
let callback = match callback {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return (StatusCode::BAD_REQUEST, "callback not provided").into_response(),
|
|
||||||
};
|
|
||||||
let subscription_id = parts
|
|
||||||
.headers
|
|
||||||
.get(HeaderName::from_static("sid"))
|
|
||||||
.and_then(|v| v.to_str().ok());
|
|
||||||
|
|
||||||
let timeout = parts
|
|
||||||
.headers
|
|
||||||
.get(HeaderName::from_static("timeout"))
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|t| t.strip_prefix("Second-"))
|
|
||||||
.and_then(|t| t.parse::<u16>().ok())
|
|
||||||
.map(|t| Duration::from_secs(t as u64));
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(1800);
|
|
||||||
|
|
||||||
let timeout = timeout.unwrap_or(DEFAULT_TIMEOUT);
|
|
||||||
|
|
||||||
if let Some(sid) = subscription_id {
|
|
||||||
match state.renew_subscription(sid, timeout) {
|
|
||||||
Ok(()) => (
|
|
||||||
StatusCode::OK,
|
|
||||||
[
|
|
||||||
("SID", sid.to_owned()),
|
|
||||||
("TIMEOUT", format!("Second-{}", timeout.as_secs())),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
Err(e) => {
|
|
||||||
warn!(sid, error=?e, "error renewing subscription");
|
|
||||||
StatusCode::NOT_FOUND.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match state.new_subscription(callback, timeout) {
|
|
||||||
Ok(sid) => (
|
|
||||||
StatusCode::OK,
|
|
||||||
[
|
|
||||||
("SID", sid),
|
|
||||||
("TIMEOUT", format!("Second-{}", timeout.as_secs())),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
Err(e) => {
|
|
||||||
warn!(error=?e, "error creating subscription");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn make_router(
|
|
||||||
friendly_name: String,
|
|
||||||
http_prefix: String,
|
|
||||||
upnp_usn: String,
|
|
||||||
browse_provider: Box<dyn ContentDirectoryBrowseProvider>,
|
|
||||||
cancellation_token: CancellationToken,
|
|
||||||
) -> anyhow::Result<axum::Router> {
|
|
||||||
let root_desc = render_root_description_xml(&RootDescriptionInputs {
|
|
||||||
friendly_name: &friendly_name,
|
|
||||||
manufacturer: "rqbit developers",
|
|
||||||
model_name: "1.0.0",
|
|
||||||
unique_id: &upnp_usn,
|
|
||||||
http_prefix: &http_prefix,
|
|
||||||
});
|
|
||||||
|
|
||||||
let state = UpnpServerStateInner::new(root_desc.into(), browse_provider, cancellation_token)
|
|
||||||
.context("error creating UPNP server")?;
|
|
||||||
|
|
||||||
let content_dir_sub_handler = {
|
|
||||||
let state = state.clone();
|
|
||||||
move |request: axum::extract::Request| async move {
|
|
||||||
subscription(State(state.clone()), request).await
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = axum::Router::new()
|
|
||||||
.route("/description.xml", get(description_xml))
|
|
||||||
.route(
|
|
||||||
"/scpd/ContentDirectory.xml",
|
|
||||||
get(|| async { include_str!("resources/templates/content_directory/scpd.xml") }),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/scpd/ConnectionManager.xml",
|
|
||||||
get(|| async { include_str!("resources/templates/connection_manager/scpd.xml") }),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/control/ContentDirectory",
|
|
||||||
post(generate_content_directory_control_response),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/control/ConnectionManager",
|
|
||||||
post(|| async { (StatusCode::NOT_IMPLEMENTED, "") }),
|
|
||||||
)
|
|
||||||
.route_service(
|
|
||||||
"/subscribe/ContentDirectory",
|
|
||||||
content_dir_sub_handler.into_service(),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/subscribe/ConnectionManager",
|
|
||||||
post(|| async { (StatusCode::NOT_IMPLEMENTED, "") }),
|
|
||||||
)
|
|
||||||
.with_state(state);
|
|
||||||
|
|
||||||
Ok(app)
|
|
||||||
}
|
|
||||||
112
crates/upnp-serve/src/http_server.rs
Normal file
112
crates/upnp-serve/src/http_server.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
handler::HandlerWithoutStateExt,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use http::header::CONTENT_TYPE;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
constants::CONTENT_TYPE_XML_UTF8,
|
||||||
|
services::content_directory::ContentDirectoryBrowseProvider,
|
||||||
|
state::{UnpnServerState, UpnpServerStateInner},
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn description_xml(State(state): State<UnpnServerState>) -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
||||||
|
state.rendered_root_description.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RootDescriptionInputs<'a> {
|
||||||
|
pub friendly_name: &'a str,
|
||||||
|
pub manufacturer: &'a str,
|
||||||
|
pub model_name: &'a str,
|
||||||
|
pub unique_id: &'a str,
|
||||||
|
pub http_prefix: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_root_description_xml(input: &RootDescriptionInputs<'_>) -> String {
|
||||||
|
format!(
|
||||||
|
include_str!("resources/templates/root_desc.tmpl.xml"),
|
||||||
|
friendly_name = input.friendly_name,
|
||||||
|
manufacturer = input.manufacturer,
|
||||||
|
model_name = input.model_name,
|
||||||
|
unique_id = input.unique_id,
|
||||||
|
http_prefix = input.http_prefix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_router(
|
||||||
|
friendly_name: String,
|
||||||
|
http_prefix: String,
|
||||||
|
upnp_usn: String,
|
||||||
|
browse_provider: Box<dyn ContentDirectoryBrowseProvider>,
|
||||||
|
cancellation_token: CancellationToken,
|
||||||
|
) -> anyhow::Result<axum::Router> {
|
||||||
|
let root_desc = render_root_description_xml(&RootDescriptionInputs {
|
||||||
|
friendly_name: &friendly_name,
|
||||||
|
manufacturer: "rqbit developers",
|
||||||
|
model_name: "1.0.0",
|
||||||
|
unique_id: &upnp_usn,
|
||||||
|
http_prefix: &http_prefix,
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = UpnpServerStateInner::new(root_desc.into(), browse_provider, cancellation_token)
|
||||||
|
.context("error creating UPNP server")?;
|
||||||
|
|
||||||
|
let content_dir_sub_handler = {
|
||||||
|
let state = state.clone();
|
||||||
|
move |request: axum::extract::Request| async move {
|
||||||
|
crate::services::content_directory::subscription::subscribe_http_handler(
|
||||||
|
State(state.clone()),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let connection_manager_sub_handler = {
|
||||||
|
let state = state.clone();
|
||||||
|
move |request: axum::extract::Request| async move {
|
||||||
|
crate::services::connection_manager::subscribe_http_handler(
|
||||||
|
State(state.clone()),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = axum::Router::new()
|
||||||
|
.route("/description.xml", get(description_xml))
|
||||||
|
.route(
|
||||||
|
"/scpd/ContentDirectory.xml",
|
||||||
|
get(|| async { include_str!("resources/templates/content_directory/scpd.xml") }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/scpd/ConnectionManager.xml",
|
||||||
|
get(|| async { include_str!("resources/templates/connection_manager/scpd.xml") }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/control/ContentDirectory",
|
||||||
|
post(crate::services::content_directory::http_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/control/ConnectionManager",
|
||||||
|
post(crate::services::connection_manager::http_handler),
|
||||||
|
)
|
||||||
|
.route_service(
|
||||||
|
"/subscribe/ContentDirectory",
|
||||||
|
content_dir_sub_handler.into_service(),
|
||||||
|
)
|
||||||
|
.route_service(
|
||||||
|
"/subscribe/ConnectionManager",
|
||||||
|
connection_manager_sub_handler.into_service(),
|
||||||
|
)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
@ -2,21 +2,20 @@ use std::{io::Write, time::Duration};
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use gethostname::gethostname;
|
use gethostname::gethostname;
|
||||||
use http_handlers::make_router;
|
|
||||||
use librqbit_sha1_wrapper::ISha1;
|
use librqbit_sha1_wrapper::ISha1;
|
||||||
|
use services::content_directory::ContentDirectoryBrowseProvider;
|
||||||
use ssdp::SsdpRunner;
|
use ssdp::SsdpRunner;
|
||||||
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
use upnp_types::content_directory::ContentDirectoryBrowseProvider;
|
|
||||||
|
|
||||||
mod constants;
|
mod constants;
|
||||||
mod http_handlers;
|
mod http_server;
|
||||||
|
pub mod services;
|
||||||
mod ssdp;
|
mod ssdp;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
mod subscriptions;
|
mod subscriptions;
|
||||||
mod templates;
|
mod templates;
|
||||||
pub mod upnp_types;
|
|
||||||
|
|
||||||
pub struct UpnpServerOptions {
|
pub struct UpnpServerOptions {
|
||||||
pub friendly_name: String,
|
pub friendly_name: String,
|
||||||
|
|
@ -78,7 +77,7 @@ impl UpnpServer {
|
||||||
.await
|
.await
|
||||||
.context("error initializing SsdpRunner")?;
|
.context("error initializing SsdpRunner")?;
|
||||||
|
|
||||||
let router = make_router(
|
let router = crate::http_server::make_router(
|
||||||
opts.friendly_name,
|
opts.friendly_name,
|
||||||
opts.http_prefix,
|
opts.http_prefix,
|
||||||
usn,
|
usn,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<s:Envelope
|
||||||
|
xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetCurrentConnectionIDsResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||||
|
<ConnectionIDs></ConnectionIDs>
|
||||||
|
</u:GetCurrentConnectionIDsResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<s:Envelope
|
||||||
|
xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetCurrentConnectionInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||||
|
<RcsID>0</RcsID>
|
||||||
|
<AVTransportID>0</AVTransportID>
|
||||||
|
<ProtocolInfo>http-get:*:*:DLNA.ORG_OP=01</ProtocolInfo>
|
||||||
|
<PeerConnectionManager></PeerConnectionManager>
|
||||||
|
<PeerConnectionID>-1</PeerConnectionID>
|
||||||
|
<Direction>Output</Direction>
|
||||||
|
<Status>OK</Status>
|
||||||
|
</u:GetCurrentConnectionInfoResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<s:Envelope
|
||||||
|
xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||||
|
<Source>http-get:*:*:DLNA.ORG_OP=01</Source>
|
||||||
|
<Sink></Sink>
|
||||||
|
</u:GetProtocolInfoResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
</argument>
|
</argument>
|
||||||
</argumentList>
|
</argumentList>
|
||||||
</action>
|
</action>
|
||||||
<action>
|
<!--action>
|
||||||
<name>PrepareForConnection</name>
|
<name>PrepareForConnection</name>
|
||||||
<argumentList>
|
<argumentList>
|
||||||
<argument>
|
<argument>
|
||||||
|
|
@ -59,8 +59,8 @@
|
||||||
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
|
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
|
||||||
</argument>
|
</argument>
|
||||||
</argumentList>
|
</argumentList>
|
||||||
</action>
|
</action-->
|
||||||
<action>
|
<!--action>
|
||||||
<name>ConnectionComplete</name>
|
<name>ConnectionComplete</name>
|
||||||
<argumentList>
|
<argumentList>
|
||||||
<argument>
|
<argument>
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
|
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
|
||||||
</argument>
|
</argument>
|
||||||
</argumentList>
|
</argumentList>
|
||||||
</action>
|
</action-->
|
||||||
<action>
|
<action>
|
||||||
<name>GetCurrentConnectionIDs</name>
|
<name>GetCurrentConnectionIDs</name>
|
||||||
<argumentList>
|
<argumentList>
|
||||||
|
|
|
||||||
81
crates/upnp-serve/src/services/connection_manager.rs
Normal file
81
crates/upnp-serve/src/services/connection_manager.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use axum::{body::Bytes, extract::State, response::IntoResponse};
|
||||||
|
use bstr::BStr;
|
||||||
|
use http::{header::CONTENT_TYPE, HeaderMap, StatusCode};
|
||||||
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
constants::CONTENT_TYPE_XML_UTF8, state::UnpnServerState, subscriptions::SubscribeRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SOAP_ACTION_GET_PROTOCOL_INFO: &[u8] =
|
||||||
|
b"\"urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo\"";
|
||||||
|
|
||||||
|
pub const SOAP_ACTION_CONNECTION_COMPLETE: &[u8] =
|
||||||
|
b"\"urn:schemas-upnp-org:service:ConnectionManager:1#ConnectionComplete\"";
|
||||||
|
|
||||||
|
pub const SOAP_ACTION_GET_CURRENT_CONNECTION_IDS: &[u8] =
|
||||||
|
b"\"urn:schemas-upnp-org:service:ConnectionManager:1#GetCurrentConnectionIDs\"";
|
||||||
|
|
||||||
|
pub const SOAP_ACTION_GET_CURRENT_CONNECTION_INFO: &[u8] =
|
||||||
|
b"\"urn:schemas-upnp-org:service:ConnectionManager:1#GetCurrentConnectionInfo\"";
|
||||||
|
|
||||||
|
pub const SOAP_ACTION_PREPARE_FOR_CONNECTION: &[u8] =
|
||||||
|
b"\"urn:schemas-upnp-org:service:ConnectionManager:1#PrepareForConnection\"";
|
||||||
|
|
||||||
|
pub(crate) async fn http_handler(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(_state): State<UnpnServerState>,
|
||||||
|
body: Bytes,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let body = BStr::new(&body);
|
||||||
|
let action = headers.get("soapaction").map(|v| BStr::new(v.as_bytes()));
|
||||||
|
trace!(?body, ?action, "received control request");
|
||||||
|
let action = match action {
|
||||||
|
Some(action) => action,
|
||||||
|
None => {
|
||||||
|
debug!("missing SOAPACTION header");
|
||||||
|
return (StatusCode::BAD_REQUEST, "").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let not_implemented = || StatusCode::NOT_IMPLEMENTED.into_response();
|
||||||
|
|
||||||
|
match action.as_ref() {
|
||||||
|
SOAP_ACTION_GET_PROTOCOL_INFO => (
|
||||||
|
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
||||||
|
include_str!("../resources/templates/connection_manager/control/get_protocol_info.xml"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
|
||||||
|
SOAP_ACTION_GET_CURRENT_CONNECTION_INFO => (
|
||||||
|
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
||||||
|
include_str!(
|
||||||
|
"../resources/templates/connection_manager/control/get_current_connection_info.xml"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
SOAP_ACTION_GET_CURRENT_CONNECTION_IDS => (
|
||||||
|
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
||||||
|
include_str!(
|
||||||
|
"../resources/templates/connection_manager/control/get_current_connection_ids.xml"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
SOAP_ACTION_PREPARE_FOR_CONNECTION => not_implemented(),
|
||||||
|
SOAP_ACTION_CONNECTION_COMPLETE => not_implemented(),
|
||||||
|
_ => StatusCode::BAD_REQUEST.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn subscribe_http_handler(
|
||||||
|
State(state): State<UnpnServerState>,
|
||||||
|
request: axum::extract::Request,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let req = match SubscribeRequest::parse(request) {
|
||||||
|
Ok(sub) => sub,
|
||||||
|
Err(err) => return err,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = state.handle_connection_manager_subscription_request(&req);
|
||||||
|
crate::subscriptions::subscription_into_response(&req, resp)
|
||||||
|
}
|
||||||
336
crates/upnp-serve/src/services/content_directory.rs
Normal file
336
crates/upnp-serve/src/services/content_directory.rs
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use axum::{body::Bytes, extract::State, response::IntoResponse};
|
||||||
|
use browse::response::ItemOrContainer;
|
||||||
|
use bstr::BStr;
|
||||||
|
use http::{header::CONTENT_TYPE, HeaderMap, StatusCode};
|
||||||
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
constants::{
|
||||||
|
CONTENT_TYPE_XML_UTF8, SOAP_ACTION_CONTENT_DIRECTORY_BROWSE,
|
||||||
|
SOAP_ACTION_GET_SYSTEM_UPDATE_ID,
|
||||||
|
},
|
||||||
|
state::UnpnServerState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod browse {
|
||||||
|
pub mod request {
|
||||||
|
use anyhow::Context;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Envelope {
|
||||||
|
#[serde(rename = "Body")]
|
||||||
|
body: Body,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Body {
|
||||||
|
#[serde(rename = "Browse")]
|
||||||
|
browse: ContentDirectoryControlRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, PartialEq, Eq, Debug)]
|
||||||
|
pub enum BrowseFlag {
|
||||||
|
BrowseDirectChildren,
|
||||||
|
BrowseMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ContentDirectoryControlRequest {
|
||||||
|
#[serde(rename = "ObjectID")]
|
||||||
|
pub object_id: usize,
|
||||||
|
#[serde(rename = "BrowseFlag")]
|
||||||
|
pub browse_flag: BrowseFlag,
|
||||||
|
#[serde(rename = "StartingIndex", default)]
|
||||||
|
pub starting_index: usize,
|
||||||
|
#[serde(rename = "RequestedCount", default)]
|
||||||
|
pub requested_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentDirectoryControlRequest {
|
||||||
|
pub fn parse(s: &str) -> anyhow::Result<Self> {
|
||||||
|
let envelope: Envelope =
|
||||||
|
quick_xml::de::from_str(s).context("error deserializing")?;
|
||||||
|
Ok(envelope.body.browse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod response {
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Container {
|
||||||
|
pub id: usize,
|
||||||
|
pub parent_id: Option<usize>,
|
||||||
|
pub children_count: Option<usize>,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Item {
|
||||||
|
pub id: usize,
|
||||||
|
pub parent_id: Option<usize>,
|
||||||
|
pub title: String,
|
||||||
|
pub mime_type: Option<mime_guess::Mime>,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ItemOrContainer {
|
||||||
|
Container(Container),
|
||||||
|
Item(Item),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render(items: impl IntoIterator<Item = ItemOrContainer>) -> String {
|
||||||
|
fn item_or_container(item_or_container: &ItemOrContainer) -> Option<String> {
|
||||||
|
fn item(item: &Item) -> Option<String> {
|
||||||
|
let mime = item.mime_type.as_ref()?;
|
||||||
|
let upnp_class = match mime.type_().as_str() {
|
||||||
|
"video" => "object.item.videoItem",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let mime = mime.to_string();
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
include_str!(
|
||||||
|
"../resources/templates/content_directory/control/browse/item.tmpl.xml"
|
||||||
|
),
|
||||||
|
id = item.id,
|
||||||
|
parent_id = item.parent_id.unwrap_or(0),
|
||||||
|
mime_type = mime,
|
||||||
|
url = item.url,
|
||||||
|
upnp_class = upnp_class,
|
||||||
|
title = item.title
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn container(item: &Container) -> String {
|
||||||
|
let child_count_tag = match item.children_count {
|
||||||
|
Some(cc) => format!("childCount=\"{}\"", cc),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
include_str!(
|
||||||
|
"../resources/templates/content_directory/control/browse/container.tmpl.xml"
|
||||||
|
),
|
||||||
|
id = item.id,
|
||||||
|
parent_id = item.parent_id.unwrap_or(0),
|
||||||
|
title = item.title,
|
||||||
|
childCountTag = child_count_tag
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
match item_or_container {
|
||||||
|
ItemOrContainer::Container(c) => Some(container(c)),
|
||||||
|
ItemOrContainer::Item(i) => item(i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Envelope<'a> {
|
||||||
|
items: &'a str,
|
||||||
|
number_returned: usize,
|
||||||
|
total_matches: usize,
|
||||||
|
update_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_response(envelope: &Envelope<'_>) -> String {
|
||||||
|
format!(
|
||||||
|
include_str!(
|
||||||
|
"../resources/templates/content_directory/control/browse/response.tmpl.xml"
|
||||||
|
),
|
||||||
|
items = envelope.items,
|
||||||
|
number_returned = envelope.number_returned,
|
||||||
|
total_matches = envelope.total_matches,
|
||||||
|
update_id = envelope.update_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_items = items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| item_or_container(&item))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let total = all_items.len();
|
||||||
|
let all_items = all_items.join("");
|
||||||
|
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let update_id = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
render_response(&Envelope {
|
||||||
|
items: &all_items,
|
||||||
|
number_returned: total,
|
||||||
|
total_matches: total,
|
||||||
|
update_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod get_system_update_id {
|
||||||
|
pub(crate) fn render_notify(update_id: u64) -> String {
|
||||||
|
format!(
|
||||||
|
include_str!(
|
||||||
|
"../resources/templates/content_directory/subscriptions/system_update_id.tmpl.xml"
|
||||||
|
),
|
||||||
|
system_update_id = update_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_response(update_id: u64) -> String {
|
||||||
|
format!(
|
||||||
|
include_str!(
|
||||||
|
"../resources/templates/content_directory/control/get_system_update_id/response.tmpl.xml"
|
||||||
|
),
|
||||||
|
id = update_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod subscription {
|
||||||
|
use axum::{extract::State, response::IntoResponse};
|
||||||
|
use http::Method;
|
||||||
|
|
||||||
|
use crate::{state::UnpnServerState, subscriptions::SubscribeRequest};
|
||||||
|
|
||||||
|
pub(crate) async fn subscribe_http_handler(
|
||||||
|
State(state): State<UnpnServerState>,
|
||||||
|
request: axum::extract::Request,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let req = match SubscribeRequest::parse(request) {
|
||||||
|
Ok(sub) => sub,
|
||||||
|
Err(err) => return err,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = state.handle_content_directory_subscription_request(&req);
|
||||||
|
crate::subscriptions::subscription_into_response(&req, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn notify_system_id_update(
|
||||||
|
url: &url::Url,
|
||||||
|
sid: &str,
|
||||||
|
seq: u64,
|
||||||
|
system_update_id: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
// NOTIFY /callback_path HTTP/1.1
|
||||||
|
// CONTENT-TYPE: text/xml; charset="utf-8"
|
||||||
|
// NT: upnp:event
|
||||||
|
// NTS: upnp:propchange
|
||||||
|
// SID: uuid:<Subscription ID>
|
||||||
|
// SEQ: <sequence number>
|
||||||
|
//
|
||||||
|
let body = super::get_system_update_id::render_notify(system_update_id);
|
||||||
|
|
||||||
|
let resp = reqwest::Client::builder()
|
||||||
|
.build()?
|
||||||
|
.request(Method::from_bytes(b"NOTIFY")?, url.clone())
|
||||||
|
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
||||||
|
.header("NT", "upnp:event")
|
||||||
|
.header("NTS", "upnp:propchange")
|
||||||
|
.header("SID", sid)
|
||||||
|
.header("SEQ", seq.to_string())
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!("{:?}", resp.status())
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn http_handler(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(state): State<UnpnServerState>,
|
||||||
|
body: Bytes,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let body = BStr::new(&body);
|
||||||
|
let action = headers.get("soapaction").map(|v| BStr::new(v.as_bytes()));
|
||||||
|
trace!(?body, ?action, "received control request");
|
||||||
|
let action = match action {
|
||||||
|
Some(action) => action,
|
||||||
|
None => {
|
||||||
|
debug!("missing SOAPACTION header");
|
||||||
|
return (StatusCode::BAD_REQUEST, "").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match action.as_ref() {
|
||||||
|
SOAP_ACTION_CONTENT_DIRECTORY_BROWSE => {
|
||||||
|
let http_hostname = headers
|
||||||
|
.get("host")
|
||||||
|
.and_then(|h| std::str::from_utf8(h.as_bytes()).ok())
|
||||||
|
.and_then(|h| h.split(':').next());
|
||||||
|
let http_hostname = match http_hostname {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = match std::str::from_utf8(body) {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(_) => return (StatusCode::BAD_REQUEST, "cannot parse request").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = match browse::request::ContentDirectoryControlRequest::parse(body) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
debug!(error=?e, "error parsing XML");
|
||||||
|
return (StatusCode::BAD_REQUEST, "cannot parse request").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use browse::request::BrowseFlag;
|
||||||
|
|
||||||
|
match request.browse_flag {
|
||||||
|
BrowseFlag::BrowseDirectChildren => (
|
||||||
|
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
||||||
|
browse::response::render(
|
||||||
|
state
|
||||||
|
.provider
|
||||||
|
.browse_direct_children(request.object_id, http_hostname),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
BrowseFlag::BrowseMetadata => StatusCode::NOT_IMPLEMENTED.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SOAP_ACTION_GET_SYSTEM_UPDATE_ID => {
|
||||||
|
let update_id = state.system_update_id.load(Ordering::Relaxed);
|
||||||
|
(
|
||||||
|
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
||||||
|
get_system_update_id::render_response(update_id),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
debug!(?action, "unsupported ContentDirectory action");
|
||||||
|
(StatusCode::NOT_IMPLEMENTED, "").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ContentDirectoryBrowseProvider: Send + Sync {
|
||||||
|
fn browse_direct_children(&self, parent_id: usize, http_hostname: &str)
|
||||||
|
-> Vec<ItemOrContainer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentDirectoryBrowseProvider for Vec<ItemOrContainer> {
|
||||||
|
fn browse_direct_children(&self, _parent_id: usize, _http_host: &str) -> Vec<ItemOrContainer> {
|
||||||
|
self.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn test_parse_content_directory_request() {
|
||||||
|
use super::browse::request::{BrowseFlag, ContentDirectoryControlRequest};
|
||||||
|
|
||||||
|
let s = include_str!("../resources/test/ContentDirectoryControlExampleRequest.xml");
|
||||||
|
let req = ContentDirectoryControlRequest::parse(s).unwrap();
|
||||||
|
assert_eq!(req.object_id, 5);
|
||||||
|
assert_eq!(req.browse_flag, BrowseFlag::BrowseDirectChildren)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
crates/upnp-serve/src/services/mod.rs
Normal file
2
crates/upnp-serve/src/services/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod connection_manager;
|
||||||
|
pub mod content_directory;
|
||||||
|
|
@ -12,19 +12,18 @@ use librqbit_core::spawn_utils::spawn_with_cancel;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{error_span, Span};
|
use tracing::{error_span, Span};
|
||||||
|
|
||||||
use crate::{
|
use crate::{subscriptions::Subscriptions, ContentDirectoryBrowseProvider};
|
||||||
subscriptions::Subscriptions, upnp_types::content_directory::ContentDirectoryBrowseProvider,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct UpnpServerStateInner {
|
pub struct UpnpServerStateInner {
|
||||||
pub rendered_root_description: Bytes,
|
pub(crate) rendered_root_description: Bytes,
|
||||||
pub provider: Box<dyn ContentDirectoryBrowseProvider>,
|
pub(crate) provider: Box<dyn ContentDirectoryBrowseProvider>,
|
||||||
pub system_update_id: AtomicU64,
|
pub(crate) system_update_id: AtomicU64,
|
||||||
pub subscriptions: Subscriptions,
|
pub(crate) content_directory_subscriptions: Subscriptions,
|
||||||
|
pub(crate) connection_manager_subscriptions: Subscriptions,
|
||||||
|
|
||||||
pub span: Span,
|
pub(crate) span: Span,
|
||||||
pub system_update_bcast_tx: tokio::sync::broadcast::Sender<u64>,
|
pub(crate) system_update_bcast_tx: tokio::sync::broadcast::Sender<u64>,
|
||||||
pub cancel_token: tokio_util::sync::CancellationToken,
|
pub(crate) cancel_token: tokio_util::sync::CancellationToken,
|
||||||
_drop_guard: tokio_util::sync::DropGuard,
|
_drop_guard: tokio_util::sync::DropGuard,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +45,8 @@ impl UpnpServerStateInner {
|
||||||
rendered_root_description,
|
rendered_root_description,
|
||||||
provider,
|
provider,
|
||||||
system_update_id: AtomicU64::new(new_system_update_id()?),
|
system_update_id: AtomicU64::new(new_system_update_id()?),
|
||||||
subscriptions: Default::default(),
|
content_directory_subscriptions: Default::default(),
|
||||||
|
connection_manager_subscriptions: Default::default(),
|
||||||
system_update_bcast_tx: btx,
|
system_update_bcast_tx: btx,
|
||||||
_drop_guard: drop_guard,
|
_drop_guard: drop_guard,
|
||||||
span: span.clone(),
|
span: span.clone(),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::state::UpnpServerStateInner;
|
use crate::state::UpnpServerStateInner;
|
||||||
use crate::templates::render_notify_subscription_system_update_id;
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use http::Method;
|
use axum::response::IntoResponse;
|
||||||
|
use http::{HeaderName, StatusCode};
|
||||||
use librqbit_core::spawn_utils::spawn_with_cancel;
|
use librqbit_core::spawn_utils::spawn_with_cancel;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use std::{
|
use std::{
|
||||||
|
|
@ -13,6 +13,7 @@ use tokio::sync::{broadcast::error::RecvError, Notify};
|
||||||
use tracing::{debug, error_span, trace, warn, Instrument};
|
use tracing::{debug, error_span, trace, warn, Instrument};
|
||||||
|
|
||||||
pub struct Subscription {
|
pub struct Subscription {
|
||||||
|
#[allow(dead_code)]
|
||||||
pub url: url::Url,
|
pub url: url::Url,
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
|
|
@ -69,50 +70,171 @@ impl Subscriptions {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn notify_subscription_system_update(
|
#[derive(Debug)]
|
||||||
url: &url::Url,
|
pub enum SubscribeRequest {
|
||||||
sid: &str,
|
Create {
|
||||||
seq: u64,
|
callback: url::Url,
|
||||||
system_update_id: u64,
|
timeout: Duration,
|
||||||
) -> anyhow::Result<()> {
|
},
|
||||||
// NOTIFY /callback_path HTTP/1.1
|
Renew {
|
||||||
// CONTENT-TYPE: text/xml; charset="utf-8"
|
sid: String,
|
||||||
// NT: upnp:event
|
timeout: Duration,
|
||||||
// NTS: upnp:propchange
|
},
|
||||||
// SID: uuid:<Subscription ID>
|
}
|
||||||
// SEQ: <sequence number>
|
|
||||||
//
|
|
||||||
let body = render_notify_subscription_system_update_id(system_update_id);
|
|
||||||
|
|
||||||
let resp = reqwest::Client::builder()
|
impl core::fmt::Display for SubscribeRequest {
|
||||||
.build()?
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
.request(Method::from_bytes(b"NOTIFY")?, url.clone())
|
match self {
|
||||||
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
SubscribeRequest::Create { callback, timeout } => {
|
||||||
.header("NT", "upnp:event")
|
write!(f, "create;callback={callback};timeout={timeout:?}")
|
||||||
.header("NTS", "upnp:propchange")
|
}
|
||||||
.header("SID", sid)
|
SubscribeRequest::Renew { sid, timeout } => {
|
||||||
.header("SEQ", seq.to_string())
|
write!(f, "renew;sid={sid};timeout={timeout:?}")
|
||||||
.body(body)
|
}
|
||||||
.send()
|
}
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
anyhow::bail!("{:?}", resp.status())
|
|
||||||
}
|
}
|
||||||
Ok(())
|
}
|
||||||
|
|
||||||
|
impl SubscribeRequest {
|
||||||
|
fn timeout(&self) -> Duration {
|
||||||
|
match self {
|
||||||
|
SubscribeRequest::Create { timeout, .. } => *timeout,
|
||||||
|
SubscribeRequest::Renew { timeout, .. } => *timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubscribeRequest {
|
||||||
|
pub fn parse(
|
||||||
|
request: axum::extract::Request,
|
||||||
|
) -> Result<SubscribeRequest, axum::response::Response> {
|
||||||
|
if request.method().as_str() != "SUBSCRIBE" {
|
||||||
|
return Err(StatusCode::METHOD_NOT_ALLOWED.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (parts, _body) = request.into_parts();
|
||||||
|
let is_event = parts
|
||||||
|
.headers
|
||||||
|
.get(HeaderName::from_static("nt"))
|
||||||
|
.map(|v| v.as_bytes() == b"upnp:event")
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let callback = parts
|
||||||
|
.headers
|
||||||
|
.get(HeaderName::from_static("callback"))
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.trim_matches(|c| c == '>' || c == '<'))
|
||||||
|
.and_then(|u| url::Url::parse(u).ok());
|
||||||
|
let subscription_id = parts
|
||||||
|
.headers
|
||||||
|
.get(HeaderName::from_static("sid"))
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
|
|
||||||
|
let timeout = parts
|
||||||
|
.headers
|
||||||
|
.get(HeaderName::from_static("timeout"))
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|t| t.strip_prefix("Second-"))
|
||||||
|
.and_then(|t| t.parse::<u16>().ok())
|
||||||
|
.map(|t| Duration::from_secs(t as u64));
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(1800);
|
||||||
|
|
||||||
|
let timeout = timeout.unwrap_or(DEFAULT_TIMEOUT).min(DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
|
match (is_event, callback, subscription_id) {
|
||||||
|
(true, Some(callback), None) => Ok(SubscribeRequest::Create { callback, timeout }),
|
||||||
|
(_, _, Some(sid)) => Ok(SubscribeRequest::Renew {
|
||||||
|
sid: sid.to_owned(),
|
||||||
|
timeout,
|
||||||
|
}),
|
||||||
|
_ => Err(StatusCode::BAD_REQUEST.into_response()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) enum SubscriptionResult {
|
||||||
|
Renewed { sid: String },
|
||||||
|
Created { sid: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubscriptionResult {
|
||||||
|
fn sid(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
SubscriptionResult::Renewed { sid } => sid,
|
||||||
|
SubscriptionResult::Created { sid } => sid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn subscription_into_response(
|
||||||
|
request: &SubscribeRequest,
|
||||||
|
result: anyhow::Result<SubscriptionResult>,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
trace!(%request, ?result, "request->response");
|
||||||
|
|
||||||
|
let result = match result {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error=?e, sub=?request, "error handling subscription request");
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("SID", result.sid().to_owned()),
|
||||||
|
("TIMEOUT", format!("Second-{}", request.timeout().as_secs())),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpnpServerStateInner {
|
impl UpnpServerStateInner {
|
||||||
pub fn renew_subscription(&self, sid: &str, new_timeout: Duration) -> anyhow::Result<()> {
|
pub(crate) fn handle_content_directory_subscription_request(
|
||||||
self.subscriptions.update_timeout(sid, new_timeout)
|
self: &Arc<Self>,
|
||||||
|
req: &SubscribeRequest,
|
||||||
|
) -> anyhow::Result<SubscriptionResult> {
|
||||||
|
match req {
|
||||||
|
SubscribeRequest::Create { callback, timeout } => {
|
||||||
|
let sid = self.new_content_directory_subscription(callback.clone(), *timeout)?;
|
||||||
|
Ok(SubscriptionResult::Created { sid })
|
||||||
|
}
|
||||||
|
SubscribeRequest::Renew { sid, timeout } => {
|
||||||
|
self.content_directory_subscriptions
|
||||||
|
.update_timeout(sid, *timeout)?;
|
||||||
|
Ok(SubscriptionResult::Renewed { sid: sid.clone() })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_subscription(
|
pub(crate) fn handle_connection_manager_subscription_request(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
req: &SubscribeRequest,
|
||||||
|
) -> anyhow::Result<SubscriptionResult> {
|
||||||
|
match req {
|
||||||
|
SubscribeRequest::Create { callback, timeout } => {
|
||||||
|
let sid = self.new_connection_manager_subscription(callback.clone(), *timeout)?;
|
||||||
|
Ok(SubscriptionResult::Created { sid })
|
||||||
|
}
|
||||||
|
SubscribeRequest::Renew { sid, timeout } => {
|
||||||
|
self.connection_manager_subscriptions
|
||||||
|
.update_timeout(sid, *timeout)?;
|
||||||
|
Ok(SubscriptionResult::Renewed { sid: sid.clone() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_content_directory_subscription(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
url: url::Url,
|
url: url::Url,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
let (sid, refresh_notify) = self.subscriptions.add(url.clone(), timeout);
|
let (sid, refresh_notify) = self
|
||||||
|
.content_directory_subscriptions
|
||||||
|
.add(url.clone(), timeout);
|
||||||
let token = self.cancel_token.child_token();
|
let token = self.cancel_token.child_token();
|
||||||
|
|
||||||
// Spawn a task that will notify it of system id changes.
|
// Spawn a task that will notify it of system id changes.
|
||||||
|
|
@ -127,37 +249,28 @@ impl UpnpServerStateInner {
|
||||||
let url = url.clone();
|
let url = url.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
|
use crate::services::content_directory::subscription::notify_system_id_update;
|
||||||
let system_update_id_notifier = async {
|
let system_update_id_notifier = async {
|
||||||
loop {
|
loop {
|
||||||
let res = brx.recv().await;
|
let res = brx.recv().await;
|
||||||
let state = state.upgrade().context("upnp server dead")?;
|
let state = state.upgrade().context("upnp server dead")?;
|
||||||
let seq = state.subscriptions.next_seq(&sid)?;
|
let seq = state.content_directory_subscriptions.next_seq(&sid)?;
|
||||||
match res {
|
match res {
|
||||||
Ok(system_update_id) => {
|
Ok(system_update_id) => {
|
||||||
trace!(system_update_id, "notifying SystemUpdateId update");
|
trace!(system_update_id, "notifying SystemUpdateId update");
|
||||||
if let Err(e) = notify_subscription_system_update(
|
if let Err(e) =
|
||||||
&url,
|
notify_system_id_update(&url, &sid, seq, system_update_id).await
|
||||||
&sid,
|
|
||||||
seq,
|
|
||||||
system_update_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
debug!(error=?e, "error updating UPNP subscription");
|
debug!(error=?e, "error updating UPNP subscription");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(RecvError::Lagged(by)) => {
|
Err(RecvError::Lagged(by)) => {
|
||||||
warn!(by, "UPNP subscription lagged");
|
warn!(by, "UPNP subscription lagged");
|
||||||
let seq = state.subscriptions.next_seq(&sid)?;
|
let seq = state.content_directory_subscriptions.next_seq(&sid)?;
|
||||||
let system_update_id =
|
let system_update_id =
|
||||||
state.system_update_id.load(Ordering::Relaxed);
|
state.system_update_id.load(Ordering::Relaxed);
|
||||||
if let Err(e) = notify_subscription_system_update(
|
if let Err(e) =
|
||||||
&url,
|
notify_system_id_update(&url, &sid, seq, system_update_id).await
|
||||||
&sid,
|
|
||||||
seq,
|
|
||||||
system_update_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
debug!(error=?e, "error updating UPNP subscription");
|
debug!(error=?e, "error updating UPNP subscription");
|
||||||
}
|
}
|
||||||
|
|
@ -173,10 +286,12 @@ impl UpnpServerStateInner {
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = refresh_notify.notified() => {
|
_ = refresh_notify.notified() => {
|
||||||
timeout = state.upgrade().context("upnp server dead")?.subscriptions.get_timeout(&sid)?;
|
timeout = state.upgrade().context("upnp server dead")?.content_directory_subscriptions.get_timeout(&sid)?;
|
||||||
|
trace!(?timeout, "refreshed subscription");
|
||||||
},
|
},
|
||||||
_ = tokio::time::sleep(timeout) => {
|
_ = tokio::time::sleep(timeout) => {
|
||||||
state.upgrade().context("upnp server dead")?.subscriptions.remove(&sid)?;
|
state.upgrade().context("upnp server dead")?.content_directory_subscriptions.remove(&sid)?;
|
||||||
|
trace!(?timeout, "subscription timed out, removing");
|
||||||
return Ok(())
|
return Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +306,55 @@ impl UpnpServerStateInner {
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn_with_cancel(
|
spawn_with_cancel(
|
||||||
error_span!(parent: pspan, "subscription-manager", sid, %url),
|
error_span!(parent: pspan, "subscription-manager", sid, %url, service="ContentDirectory"),
|
||||||
|
token,
|
||||||
|
subscription_manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_connection_manager_subscription(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
url: url::Url,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let (sid, refresh_notify) = self
|
||||||
|
.connection_manager_subscriptions
|
||||||
|
.add(url.clone(), timeout);
|
||||||
|
let token = self.cancel_token.child_token();
|
||||||
|
|
||||||
|
// Spawn a task that will notify it of system id changes.
|
||||||
|
// Spawn a task that will wait for timeout or subscription refreshes.
|
||||||
|
// When it times out, kill all of them.
|
||||||
|
|
||||||
|
let pspan = self.span.clone();
|
||||||
|
let subscription_manager = {
|
||||||
|
let state = Arc::downgrade(self);
|
||||||
|
let sid = sid.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let timeout_notifier = async {
|
||||||
|
let mut timeout = timeout;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = refresh_notify.notified() => {
|
||||||
|
timeout = state.upgrade().context("upnp server dead")?.connection_manager_subscriptions.get_timeout(&sid)?;
|
||||||
|
},
|
||||||
|
_ = tokio::time::sleep(timeout) => {
|
||||||
|
state.upgrade().context("upnp server dead")?.connection_manager_subscriptions.remove(&sid)?;
|
||||||
|
return Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.instrument(error_span!("timeout-killer"));
|
||||||
|
|
||||||
|
timeout_notifier.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
spawn_with_cancel(
|
||||||
|
error_span!(parent: pspan, "subscription-manager", sid, %url, service="ConnectionManager"),
|
||||||
token,
|
token,
|
||||||
subscription_manager,
|
subscription_manager,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,119 +1 @@
|
||||||
use crate::upnp_types::content_directory::response::{Container, Item, ItemOrContainer};
|
|
||||||
|
|
||||||
pub struct RootDescriptionInputs<'a> {
|
|
||||||
pub friendly_name: &'a str,
|
|
||||||
pub manufacturer: &'a str,
|
|
||||||
pub model_name: &'a str,
|
|
||||||
pub unique_id: &'a str,
|
|
||||||
pub http_prefix: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_root_description_xml(input: &RootDescriptionInputs<'_>) -> String {
|
|
||||||
format!(
|
|
||||||
include_str!("resources/templates/root_desc.tmpl.xml"),
|
|
||||||
friendly_name = input.friendly_name,
|
|
||||||
manufacturer = input.manufacturer,
|
|
||||||
model_name = input.model_name,
|
|
||||||
unique_id = input.unique_id,
|
|
||||||
http_prefix = input.http_prefix
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_content_directory_browse(items: impl IntoIterator<Item = ItemOrContainer>) -> String {
|
|
||||||
fn item_or_container(item_or_container: &ItemOrContainer) -> Option<String> {
|
|
||||||
fn item(item: &Item) -> Option<String> {
|
|
||||||
let mime = item.mime_type.as_ref()?;
|
|
||||||
let upnp_class = match mime.type_().as_str() {
|
|
||||||
"video" => "object.item.videoItem",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let mime = mime.to_string();
|
|
||||||
|
|
||||||
Some(format!(
|
|
||||||
include_str!("resources/templates/content_directory/control/browse/item.tmpl.xml"),
|
|
||||||
id = item.id,
|
|
||||||
parent_id = item.parent_id.unwrap_or(0),
|
|
||||||
mime_type = mime,
|
|
||||||
url = item.url,
|
|
||||||
upnp_class = upnp_class,
|
|
||||||
title = item.title
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn container(item: &Container) -> String {
|
|
||||||
let child_count_tag = match item.children_count {
|
|
||||||
Some(cc) => format!("childCount=\"{}\"", cc),
|
|
||||||
None => String::new(),
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
include_str!(
|
|
||||||
"resources/templates/content_directory/control/browse/container.tmpl.xml"
|
|
||||||
),
|
|
||||||
id = item.id,
|
|
||||||
parent_id = item.parent_id.unwrap_or(0),
|
|
||||||
title = item.title,
|
|
||||||
childCountTag = child_count_tag
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
match item_or_container {
|
|
||||||
ItemOrContainer::Container(c) => Some(container(c)),
|
|
||||||
ItemOrContainer::Item(i) => item(i),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Envelope<'a> {
|
|
||||||
items: &'a str,
|
|
||||||
number_returned: usize,
|
|
||||||
total_matches: usize,
|
|
||||||
update_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_response(envelope: &Envelope<'_>) -> String {
|
|
||||||
format!(
|
|
||||||
include_str!("resources/templates/content_directory/control/browse/response.tmpl.xml"),
|
|
||||||
items = envelope.items,
|
|
||||||
number_returned = envelope.number_returned,
|
|
||||||
total_matches = envelope.total_matches,
|
|
||||||
update_id = envelope.update_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let all_items = items
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|item| item_or_container(&item))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let total = all_items.len();
|
|
||||||
let all_items = all_items.join("");
|
|
||||||
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
let update_id = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
render_response(&Envelope {
|
|
||||||
items: &all_items,
|
|
||||||
number_returned: total,
|
|
||||||
total_matches: total,
|
|
||||||
update_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_notify_subscription_system_update_id(update_id: u64) -> String {
|
|
||||||
format!(
|
|
||||||
include_str!(
|
|
||||||
"resources/templates/content_directory/subscriptions/system_update_id.tmpl.xml"
|
|
||||||
),
|
|
||||||
system_update_id = update_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_content_directory_control_get_system_update_id(update_id: u64) -> String {
|
|
||||||
format!(
|
|
||||||
include_str!(
|
|
||||||
"resources/templates/content_directory/control/get_system_update_id/response.tmpl.xml"
|
|
||||||
),
|
|
||||||
id = update_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
pub mod content_directory {
|
|
||||||
use response::ItemOrContainer;
|
|
||||||
|
|
||||||
pub mod request {
|
|
||||||
use anyhow::Context;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Envelope {
|
|
||||||
#[serde(rename = "Body")]
|
|
||||||
body: Body,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Body {
|
|
||||||
#[serde(rename = "Browse")]
|
|
||||||
browse: ContentDirectoryControlRequest,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, PartialEq, Eq, Debug)]
|
|
||||||
pub enum BrowseFlag {
|
|
||||||
BrowseDirectChildren,
|
|
||||||
BrowseMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
pub struct ContentDirectoryControlRequest {
|
|
||||||
#[serde(rename = "ObjectID")]
|
|
||||||
pub object_id: usize,
|
|
||||||
#[serde(rename = "BrowseFlag")]
|
|
||||||
pub browse_flag: BrowseFlag,
|
|
||||||
#[serde(rename = "StartingIndex", default)]
|
|
||||||
pub starting_index: usize,
|
|
||||||
#[serde(rename = "RequestedCount", default)]
|
|
||||||
pub requested_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ContentDirectoryControlRequest {
|
|
||||||
pub fn parse(s: &str) -> anyhow::Result<Self> {
|
|
||||||
let envelope: Envelope =
|
|
||||||
quick_xml::de::from_str(s).context("error deserializing")?;
|
|
||||||
Ok(envelope.body.browse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod response {
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Container {
|
|
||||||
pub id: usize,
|
|
||||||
pub parent_id: Option<usize>,
|
|
||||||
pub children_count: Option<usize>,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Item {
|
|
||||||
pub id: usize,
|
|
||||||
pub parent_id: Option<usize>,
|
|
||||||
pub title: String,
|
|
||||||
pub mime_type: Option<mime_guess::Mime>,
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum ItemOrContainer {
|
|
||||||
Container(Container),
|
|
||||||
Item(Item),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait ContentDirectoryBrowseProvider: Send + Sync {
|
|
||||||
fn browse_direct_children(
|
|
||||||
&self,
|
|
||||||
parent_id: usize,
|
|
||||||
http_hostname: &str,
|
|
||||||
) -> Vec<ItemOrContainer>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ContentDirectoryBrowseProvider for Vec<ItemOrContainer> {
|
|
||||||
fn browse_direct_children(
|
|
||||||
&self,
|
|
||||||
_parent_id: usize,
|
|
||||||
_http_host: &str,
|
|
||||||
) -> Vec<ItemOrContainer> {
|
|
||||||
self.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::upnp_types::content_directory::request::{
|
|
||||||
BrowseFlag, ContentDirectoryControlRequest,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_content_directory_request() {
|
|
||||||
let s = include_str!("resources/test/ContentDirectoryControlExampleRequest.xml");
|
|
||||||
let req = ContentDirectoryControlRequest::parse(s).unwrap();
|
|
||||||
assert_eq!(req.object_id, 5);
|
|
||||||
assert_eq!(req.browse_flag, BrowseFlag::BrowseDirectChildren)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue