UPNP server integrated into rqbit.
How to use: https://github.com/ikatson/rqbit/pull/208
This commit is contained in:
parent
e8bd7ca7e5
commit
9e7b656f0b
34 changed files with 2420 additions and 234 deletions
132
Cargo.lock
generated
132
Cargo.lock
generated
|
|
@ -200,12 +200,6 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "atomic-waker"
|
|
||||||
version = "1.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
|
|
@ -401,6 +395,17 @@ dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bstr"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"regex-automata 0.4.7",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.16.0"
|
version = "3.16.0"
|
||||||
|
|
@ -793,15 +798,6 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "encoding_rs"
|
|
||||||
version = "0.8.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|
@ -1041,6 +1037,16 @@ dependencies = [
|
||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gethostname"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
|
|
@ -1077,25 +1083,6 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h2"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205"
|
|
||||||
dependencies = [
|
|
||||||
"atomic-waker",
|
|
||||||
"bytes",
|
|
||||||
"fnv",
|
|
||||||
"futures-core",
|
|
||||||
"futures-sink",
|
|
||||||
"http 1.1.0",
|
|
||||||
"indexmap 2.4.0",
|
|
||||||
"slab",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
|
|
@ -1275,7 +1262,7 @@ dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2 0.3.26",
|
"h2",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
|
@ -1298,7 +1285,6 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2 0.4.6",
|
|
||||||
"http 1.1.0",
|
"http 1.1.0",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
|
@ -1576,6 +1562,7 @@ dependencies = [
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"upnp-serve",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|
@ -2284,6 +2271,15 @@ dependencies = [
|
||||||
"prost",
|
"prost",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.36.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96a05e2e8efddfa51a84ca47cec303fac86c8541b686d37cac5efc0e094417bc"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
|
|
@ -2458,10 +2454,8 @@ checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2 0.4.6",
|
|
||||||
"http 1.1.0",
|
"http 1.1.0",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
|
@ -2485,7 +2479,6 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper 1.0.1",
|
"sync_wrapper 1.0.1",
|
||||||
"system-configuration",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
|
@ -2544,6 +2537,7 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"upnp-serve",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2827,6 +2821,15 @@ version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signature"
|
name = "signature"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
|
|
@ -3159,27 +3162,6 @@ dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "system-configuration"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "658bc6ee10a9b4fcf576e9b0819d95ec16f4d2c02d39fd83ac1c8789785c4a42"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.6.0",
|
|
||||||
"core-foundation",
|
|
||||||
"system-configuration-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "system-configuration-sys"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tap"
|
name = "tap"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|
@ -3295,7 +3277,9 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -3405,7 +3389,7 @@ dependencies = [
|
||||||
"axum 0.6.20",
|
"axum 0.6.20",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"bytes",
|
"bytes",
|
||||||
"h2 0.3.26",
|
"h2",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"hyper 0.14.30",
|
"hyper 0.14.30",
|
||||||
|
|
@ -3605,6 +3589,32 @@ version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "upnp-serve"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"axum 0.7.5",
|
||||||
|
"bstr",
|
||||||
|
"gethostname",
|
||||||
|
"http 1.1.0",
|
||||||
|
"httparse",
|
||||||
|
"librqbit-core",
|
||||||
|
"librqbit-sha1-wrapper",
|
||||||
|
"librqbit-upnp",
|
||||||
|
"mime_guess",
|
||||||
|
"parking_lot",
|
||||||
|
"quick-xml",
|
||||||
|
"reqwest",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.2"
|
version = "2.5.2"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ members = [
|
||||||
"crates/peer_binary_protocol",
|
"crates/peer_binary_protocol",
|
||||||
"crates/dht",
|
"crates/dht",
|
||||||
"crates/upnp",
|
"crates/upnp",
|
||||||
"crates/tracker_comms",
|
"crates/tracker_comms", "crates/upnp-serve",
|
||||||
]
|
]
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -20,8 +20,10 @@ devserver:
|
||||||
echo -n '' > /tmp/rqbit-log && CORS_ALLOW_REGEXP=".*" \
|
echo -n '' > /tmp/rqbit-log && CORS_ALLOW_REGEXP=".*" \
|
||||||
cargo run -- \
|
cargo run -- \
|
||||||
--log-file /tmp/rqbit-log \
|
--log-file /tmp/rqbit-log \
|
||||||
--log-file-rust-log=debug,librqbit=trace \
|
--log-file-rust-log=debug,librqbit=trace,upnp_serve=trace \
|
||||||
--http-api-listen-addr 0.0.0.0:3030 \
|
--http-api-listen-addr 0.0.0.0:3030 \
|
||||||
|
--upnp-server-hostname 192.168.0.112 \
|
||||||
|
--upnp-server-friendly-name rqbit-dev \
|
||||||
server start /tmp/scratch/
|
server start /tmp/scratch/
|
||||||
|
|
||||||
@PHONY: devserver
|
@PHONY: devserver
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ readme = "README.md"
|
||||||
[features]
|
[features]
|
||||||
default = ["default-tls"]
|
default = ["default-tls"]
|
||||||
http-api = ["axum", "tower-http"]
|
http-api = ["axum", "tower-http"]
|
||||||
|
upnp-serve-adapter = ["upnp-serve"]
|
||||||
webui = []
|
webui = []
|
||||||
timed_existence = []
|
timed_existence = []
|
||||||
default-tls = ["reqwest/default-tls"]
|
default-tls = ["reqwest/default-tls"]
|
||||||
|
|
@ -38,6 +39,7 @@ peer_binary_protocol = { path = "../peer_binary_protocol", package = "librqbit-p
|
||||||
sha1w = { path = "../sha1w", default-features = false, package = "librqbit-sha1-wrapper", version = "3.0.0" }
|
sha1w = { path = "../sha1w", default-features = false, package = "librqbit-sha1-wrapper", version = "3.0.0" }
|
||||||
dht = { path = "../dht", package = "librqbit-dht", version = "5.1.0" }
|
dht = { path = "../dht", package = "librqbit-dht", version = "5.1.0" }
|
||||||
librqbit-upnp = { path = "../upnp", version = "0.1.1" }
|
librqbit-upnp = { path = "../upnp", version = "0.1.1" }
|
||||||
|
upnp-serve = { path = "../upnp-serve", version = "0.1.0", optional = true }
|
||||||
|
|
||||||
tokio = { version = "1", features = [
|
tokio = { version = "1", features = [
|
||||||
"macros",
|
"macros",
|
||||||
|
|
@ -80,7 +82,7 @@ backoff = "0.4.0"
|
||||||
dashmap = "5.5.3"
|
dashmap = "5.5.3"
|
||||||
base64 = "0.21.5"
|
base64 = "0.21.5"
|
||||||
serde_with = "3.4.0"
|
serde_with = "3.4.0"
|
||||||
tokio-util = "0.7.10"
|
tokio-util = { version = "0.7.10", features = ["io"] }
|
||||||
bytes = "1.5.0"
|
bytes = "1.5.0"
|
||||||
rlimit = "0.10.1"
|
rlimit = "0.10.1"
|
||||||
async-stream = "0.3.5"
|
async-stream = "0.3.5"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{ConnectInfo, Path, Query, Request, State};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use bencode::AsDisplay;
|
use bencode::AsDisplay;
|
||||||
|
|
@ -16,7 +16,8 @@ use std::net::SocketAddr;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::AsyncSeekExt;
|
use tokio::io::AsyncSeekExt;
|
||||||
use tracing::{debug, info, trace};
|
use tokio::net::TcpListener;
|
||||||
|
use tracing::{debug, error_span, trace};
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
|
|
@ -52,7 +53,11 @@ impl HttpApi {
|
||||||
/// Run the HTTP server forever on the given address.
|
/// Run the HTTP server forever on the given address.
|
||||||
/// If read_only is passed, no state-modifying methods will be exposed.
|
/// If read_only is passed, no state-modifying methods will be exposed.
|
||||||
#[inline(never)]
|
#[inline(never)]
|
||||||
pub fn make_http_api_and_run(self, addr: SocketAddr) -> BoxFuture<'static, anyhow::Result<()>> {
|
pub fn make_http_api_and_run(
|
||||||
|
self,
|
||||||
|
listener: TcpListener,
|
||||||
|
upnp_router: Option<Router>,
|
||||||
|
) -> BoxFuture<'static, anyhow::Result<()>> {
|
||||||
let state = self.inner;
|
let state = self.inner;
|
||||||
|
|
||||||
async fn api_root() -> impl IntoResponse {
|
async fn api_root() -> impl IntoResponse {
|
||||||
|
|
@ -558,22 +563,33 @@ impl HttpApi {
|
||||||
.allow_headers(AllowHeaders::any())
|
.allow_headers(AllowHeaders::any())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut app = app.with_state(state);
|
||||||
|
|
||||||
|
if let Some(upnp_router) = upnp_router {
|
||||||
|
app = app.nest("/upnp", upnp_router);
|
||||||
|
}
|
||||||
|
|
||||||
let app = app
|
let app = app
|
||||||
.layer(cors_layer)
|
.layer(cors_layer)
|
||||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
.layer(
|
||||||
.with_state(state)
|
tower_http::trace::TraceLayer::new_for_http().make_span_with(|req: &Request| {
|
||||||
.into_make_service();
|
let method = req.method();
|
||||||
|
let uri = req.uri();
|
||||||
info!(%addr, "starting HTTP server");
|
if let Some(ConnectInfo(addr)) =
|
||||||
|
req.extensions().get::<ConnectInfo<SocketAddr>>()
|
||||||
use tokio::net::TcpListener;
|
{
|
||||||
|
error_span!("request", %method, %uri, %addr)
|
||||||
|
} else {
|
||||||
|
error_span!("request", %method, %uri)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_make_service_with_connect_info::<SocketAddr>();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let listener = TcpListener::bind(&addr)
|
axum::serve(listener, app)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("error binding to {addr}"))?;
|
.context("error running HTTP API")
|
||||||
axum::serve(listener, app).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
.boxed()
|
.boxed()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ mod torrent_state;
|
||||||
#[cfg(feature = "tracing-subscriber-utils")]
|
#[cfg(feature = "tracing-subscriber-utils")]
|
||||||
pub mod tracing_subscriber_config_utils;
|
pub mod tracing_subscriber_config_utils;
|
||||||
mod type_aliases;
|
mod type_aliases;
|
||||||
|
#[cfg(all(feature = "http-api", feature = "upnp-serve-adapter"))]
|
||||||
|
pub mod upnp_server_adapter;
|
||||||
|
|
||||||
pub use api::Api;
|
pub use api::Api;
|
||||||
pub use api_error::ApiError;
|
pub use api_error::ApiError;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context};
|
||||||
use axum::{response::IntoResponse, routing::get, Router};
|
|
||||||
use librqbit_core::Id20;
|
use librqbit_core::Id20;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use rand::{thread_rng, Rng, RngCore, SeedableRng};
|
use rand::{thread_rng, Rng, RngCore, SeedableRng};
|
||||||
|
|
@ -96,7 +95,9 @@ impl TestPeerMetadata {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "http-api")]
|
||||||
async fn debug_server() -> anyhow::Result<()> {
|
async fn debug_server() -> anyhow::Result<()> {
|
||||||
|
use axum::{response::IntoResponse, routing::get, Router};
|
||||||
async fn backtraces() -> impl IntoResponse {
|
async fn backtraces() -> impl IntoResponse {
|
||||||
#[cfg(feature = "async-bt")]
|
#[cfg(feature = "async-bt")]
|
||||||
{
|
{
|
||||||
|
|
@ -127,6 +128,11 @@ async fn debug_server() -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "http-api"))]
|
||||||
|
async fn debug_server() -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_debug_server() {
|
pub fn spawn_debug_server() {
|
||||||
tokio::spawn(debug_server());
|
tokio::spawn(debug_server());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
589
crates/librqbit/src/upnp_server_adapter.rs
Normal file
589
crates/librqbit/src/upnp_server_adapter.rs
Normal file
|
|
@ -0,0 +1,589 @@
|
||||||
|
use std::{
|
||||||
|
collections::{
|
||||||
|
hash_map::Entry::{Occupied, Vacant},
|
||||||
|
HashMap,
|
||||||
|
},
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{session::TorrentId, ManagedTorrent, Session};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UpnpServerSessionAdapter {
|
||||||
|
session: Arc<Session>,
|
||||||
|
hostname: String,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use buffers::ByteBufOwned;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use librqbit_core::torrent_metainfo::TorrentMetaV1Info;
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
use upnp_serve::{
|
||||||
|
upnp_types::content_directory::{
|
||||||
|
response::{Container, Item, ItemOrContainer},
|
||||||
|
ContentDirectoryBrowseProvider,
|
||||||
|
},
|
||||||
|
UpnpServer, UpnpServerOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
struct TorrentFileTreeNode {
|
||||||
|
title: String,
|
||||||
|
parent_id: Option<usize>,
|
||||||
|
children: Vec<usize>,
|
||||||
|
|
||||||
|
real_torrent_file_id: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_id(local_id: usize, torrent_id: usize) -> usize {
|
||||||
|
(local_id << 16) | (torrent_id + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_id(id: usize) -> anyhow::Result<(usize, usize)> {
|
||||||
|
let torrent_id = id & 0xffff;
|
||||||
|
if torrent_id == 0 {
|
||||||
|
anyhow::bail!("invalid id")
|
||||||
|
}
|
||||||
|
let torrent_id = torrent_id - 1;
|
||||||
|
Ok((id >> 16, torrent_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentFileTreeNode {
|
||||||
|
fn as_item_or_container(
|
||||||
|
&self,
|
||||||
|
id: usize,
|
||||||
|
torrent: &ManagedTorrent,
|
||||||
|
adapter: &UpnpServerSessionAdapter,
|
||||||
|
) -> ItemOrContainer {
|
||||||
|
let encoded_id = encode_id(id, torrent.id());
|
||||||
|
let encoded_parent_id = self.parent_id.map(|p| encode_id(p, torrent.id()));
|
||||||
|
match self.real_torrent_file_id {
|
||||||
|
Some(fid) => {
|
||||||
|
let filename = &torrent.shared().file_infos[fid].relative_filename;
|
||||||
|
let last_url_bit = filename.to_str().unwrap_or(&self.title);
|
||||||
|
return ItemOrContainer::Item(Item {
|
||||||
|
id: encoded_id,
|
||||||
|
parent_id: encoded_parent_id,
|
||||||
|
title: self.title.clone(),
|
||||||
|
mime_type: mime_guess::from_path(
|
||||||
|
&torrent.shared().file_infos[fid].relative_filename,
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
|
url: format!(
|
||||||
|
"http://{}:{}/torrents/{}/stream/{}/{}",
|
||||||
|
adapter.hostname,
|
||||||
|
adapter.port,
|
||||||
|
torrent.id(),
|
||||||
|
fid,
|
||||||
|
last_url_bit
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => ItemOrContainer::Container(Container {
|
||||||
|
id: encoded_id,
|
||||||
|
parent_id: encoded_parent_id,
|
||||||
|
title: self.title.clone(),
|
||||||
|
children_count: Some(self.children.len()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TorrentFileTree {
|
||||||
|
// root id is 0
|
||||||
|
nodes: Vec<TorrentFileTreeNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_single_file_at_root(info: &TorrentMetaV1Info<ByteBufOwned>) -> bool {
|
||||||
|
info.iter_filenames_and_lengths()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.flat_map(|(f, _)| f.iter_components())
|
||||||
|
.nth(1)
|
||||||
|
.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentFileTree {
|
||||||
|
fn build(torent_id: TorrentId, info: &TorrentMetaV1Info<ByteBufOwned>) -> anyhow::Result<Self> {
|
||||||
|
if is_single_file_at_root(info) {
|
||||||
|
let filename = info
|
||||||
|
.iter_filenames_and_lengths()?
|
||||||
|
.next()
|
||||||
|
.context("bug")?
|
||||||
|
.0
|
||||||
|
.iter_components()
|
||||||
|
.last()
|
||||||
|
.context("bug")??;
|
||||||
|
let root_node = TorrentFileTreeNode {
|
||||||
|
title: filename.to_owned(),
|
||||||
|
parent_id: None,
|
||||||
|
children: vec![],
|
||||||
|
real_torrent_file_id: Some(0),
|
||||||
|
};
|
||||||
|
return Ok(TorrentFileTree {
|
||||||
|
nodes: vec![root_node],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let root_node = TorrentFileTreeNode {
|
||||||
|
title: match info.name.as_ref() {
|
||||||
|
Some(n) => std::str::from_utf8(n)?.to_owned(),
|
||||||
|
None => {
|
||||||
|
format!("torrent {}", torent_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parent_id: None,
|
||||||
|
children: vec![],
|
||||||
|
real_torrent_file_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tree = TorrentFileTree {
|
||||||
|
nodes: vec![root_node],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut name_cache = HashMap::new();
|
||||||
|
|
||||||
|
for (fid, (fi, _)) in info.iter_filenames_and_lengths()?.enumerate() {
|
||||||
|
let components = match fi.to_vec() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let mut parent_id = 0;
|
||||||
|
let mut it = components.iter().peekable();
|
||||||
|
while let Some(component) = it.next() {
|
||||||
|
let is_last = it.peek().is_none();
|
||||||
|
if is_last {
|
||||||
|
let current_id = tree.nodes.len();
|
||||||
|
let node = TorrentFileTreeNode {
|
||||||
|
title: component.clone(),
|
||||||
|
parent_id: Some(parent_id),
|
||||||
|
children: vec![],
|
||||||
|
real_torrent_file_id: Some(fid),
|
||||||
|
};
|
||||||
|
tree.nodes.push(node);
|
||||||
|
tree.nodes[parent_id].children.push(current_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
parent_id = match name_cache.entry((parent_id, component.clone())) {
|
||||||
|
Occupied(occ) => *occ.get(),
|
||||||
|
Vacant(vac) => {
|
||||||
|
let id = tree.nodes.len();
|
||||||
|
let node = TorrentFileTreeNode {
|
||||||
|
title: component.clone(),
|
||||||
|
parent_id: Some(parent_id),
|
||||||
|
children: vec![],
|
||||||
|
real_torrent_file_id: None,
|
||||||
|
};
|
||||||
|
tree.nodes.push(node);
|
||||||
|
tree.nodes[parent_id].children.push(id);
|
||||||
|
vac.insert(id);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tree)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpnpServerSessionAdapter {
|
||||||
|
fn build_root(&self) -> Vec<ItemOrContainer> {
|
||||||
|
let mut all = self
|
||||||
|
.session
|
||||||
|
.with_torrents(|torrents| torrents.map(|(_, t)| t.clone()).collect_vec());
|
||||||
|
|
||||||
|
all.sort_unstable_by_key(|t| t.id());
|
||||||
|
|
||||||
|
all.iter()
|
||||||
|
.filter_map(|t| {
|
||||||
|
let real_id = t.id();
|
||||||
|
let upnp_id = real_id + 1;
|
||||||
|
|
||||||
|
if is_single_file_at_root(&t.shared().info) {
|
||||||
|
// Just add the file directly
|
||||||
|
let rf = &t.shared().file_infos[0].relative_filename;
|
||||||
|
let title = rf.file_name()?.to_str()?.to_owned();
|
||||||
|
let mime_type = mime_guess::from_path(rf).first();
|
||||||
|
let url = format!(
|
||||||
|
"http://{}:{}/torrents/{real_id}/stream/0/{title}",
|
||||||
|
self.hostname, self.port
|
||||||
|
);
|
||||||
|
Some(ItemOrContainer::Item(Item {
|
||||||
|
id: upnp_id,
|
||||||
|
parent_id: None,
|
||||||
|
title,
|
||||||
|
mime_type,
|
||||||
|
url,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
let title = t
|
||||||
|
.shared()
|
||||||
|
.info
|
||||||
|
.name
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| std::str::from_utf8(&b.0).ok())
|
||||||
|
.map(|n| n.to_owned())
|
||||||
|
.unwrap_or_else(|| format!("torrent {real_id}"));
|
||||||
|
|
||||||
|
// Create a folder
|
||||||
|
Some(ItemOrContainer::Container(Container {
|
||||||
|
id: upnp_id,
|
||||||
|
parent_id: None,
|
||||||
|
title,
|
||||||
|
children_count: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter {
|
||||||
|
fn browse_direct_children(&self, parent_id: usize) -> Vec<ItemOrContainer> {
|
||||||
|
if parent_id == 0 {
|
||||||
|
return self.build_root();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (node_id, torrent_id) = match decode_id(parent_id) {
|
||||||
|
Ok((node_id, torrent_id)) => (node_id, torrent_id),
|
||||||
|
Err(_) => {
|
||||||
|
debug!(id=?parent_id, "invalid id");
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trace!(parent_id, node_id, torrent_id);
|
||||||
|
|
||||||
|
let torrent = match self.session.get(torrent_id.into()) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
warn!(torrent_id, "no such torrent");
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tree = match TorrentFileTree::build(torrent.id(), &torrent.shared().info) {
|
||||||
|
Ok(tree) => tree,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(parent_id, error=?e, "error building torrent file tree");
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let node = match tree.nodes.get(node_id) {
|
||||||
|
Some(n) => n,
|
||||||
|
None => {
|
||||||
|
warn!(torrent_id, node_id, "no such internal ID in torrent");
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
trace!(node_id, torrent_id, ?node);
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
if node.real_torrent_file_id.is_some() {
|
||||||
|
result.push(node.as_item_or_container(node_id, &torrent, self))
|
||||||
|
} else {
|
||||||
|
for (child_node_id, child_node) in node
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| Some((*id, tree.nodes.get(*id)?)))
|
||||||
|
{
|
||||||
|
result.push(child_node.as_item_or_container(child_node_id, &torrent, self));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub async fn make_upnp_adapter(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
friendly_name: String,
|
||||||
|
http_hostname: String,
|
||||||
|
http_listen_port: u16,
|
||||||
|
) -> anyhow::Result<UpnpServer> {
|
||||||
|
UpnpServer::new(UpnpServerOptions {
|
||||||
|
friendly_name,
|
||||||
|
http_hostname: http_hostname.clone(),
|
||||||
|
http_listen_port,
|
||||||
|
http_prefix: "/upnp".to_owned(),
|
||||||
|
browse_provider: Box::new(UpnpServerSessionAdapter {
|
||||||
|
session: self.clone(),
|
||||||
|
hostname: http_hostname,
|
||||||
|
port: http_listen_port,
|
||||||
|
}),
|
||||||
|
cancellation_token: self.cancellation_token().child_token(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("error creating upnp adapter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use bencode::bencode_serialize_to_writer;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use dht::Id20;
|
||||||
|
use librqbit_core::torrent_metainfo::{
|
||||||
|
TorrentMetaV1File, TorrentMetaV1Info, TorrentMetaV1Owned,
|
||||||
|
};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use upnp_serve::upnp_types::content_directory::{
|
||||||
|
response::{Container, Item, ItemOrContainer},
|
||||||
|
ContentDirectoryBrowseProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
tests::test_util::setup_test_logging,
|
||||||
|
upnp_server_adapter::{
|
||||||
|
decode_id, encode_id, TorrentFileTree, TorrentFileTreeNode, UpnpServerSessionAdapter,
|
||||||
|
},
|
||||||
|
AddTorrent, AddTorrentOptions, Session, SessionOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn create_torrent(name: Option<&str>, files: &[&str]) -> TorrentMetaV1Owned {
|
||||||
|
TorrentMetaV1Owned {
|
||||||
|
announce: None,
|
||||||
|
announce_list: vec![],
|
||||||
|
info: TorrentMetaV1Info {
|
||||||
|
name: name.map(|n| n.as_bytes().into()),
|
||||||
|
pieces: b""[..].into(),
|
||||||
|
piece_length: 1,
|
||||||
|
length: None,
|
||||||
|
md5sum: None,
|
||||||
|
files: Some(
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|f| TorrentMetaV1File {
|
||||||
|
length: 1,
|
||||||
|
path: f.split("/").map(|f| f.as_bytes().into()).collect(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
comment: None,
|
||||||
|
created_by: None,
|
||||||
|
encoding: None,
|
||||||
|
publisher: None,
|
||||||
|
publisher_url: None,
|
||||||
|
creation_date: None,
|
||||||
|
info_hash: Id20::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_torrent_file_tree_single() -> anyhow::Result<()> {
|
||||||
|
let t = create_torrent(Some("test t"), &["file0"]);
|
||||||
|
let tree = TorrentFileTree::build(0, &t.info)?;
|
||||||
|
assert_eq!(
|
||||||
|
&tree.nodes,
|
||||||
|
&[TorrentFileTreeNode {
|
||||||
|
children: vec![],
|
||||||
|
parent_id: None,
|
||||||
|
real_torrent_file_id: Some(0),
|
||||||
|
title: "file0".into()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_torrent_file_tree_flat() -> anyhow::Result<()> {
|
||||||
|
let t = create_torrent(Some("test t"), &["file0", "file1"]);
|
||||||
|
let tree = TorrentFileTree::build(0, &t.info)?;
|
||||||
|
assert_eq!(
|
||||||
|
&tree.nodes,
|
||||||
|
&[
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![1, 2],
|
||||||
|
parent_id: None,
|
||||||
|
real_torrent_file_id: None,
|
||||||
|
title: "test t".into()
|
||||||
|
},
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![],
|
||||||
|
parent_id: Some(0),
|
||||||
|
real_torrent_file_id: Some(0),
|
||||||
|
title: "file0".into()
|
||||||
|
},
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![],
|
||||||
|
parent_id: Some(0),
|
||||||
|
real_torrent_file_id: Some(1),
|
||||||
|
title: "file1".into()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_torrent_file_tree_nested() -> anyhow::Result<()> {
|
||||||
|
let t = create_torrent(
|
||||||
|
Some("test t"),
|
||||||
|
&["file0", "file1", "dir0/file2", "dir0/dir1/file3"],
|
||||||
|
);
|
||||||
|
let tree = TorrentFileTree::build(0, &t.info)?;
|
||||||
|
assert_eq!(
|
||||||
|
&tree.nodes,
|
||||||
|
&[
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![1, 2, 3],
|
||||||
|
parent_id: None,
|
||||||
|
real_torrent_file_id: None,
|
||||||
|
title: "test t".into()
|
||||||
|
},
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![],
|
||||||
|
parent_id: Some(0),
|
||||||
|
real_torrent_file_id: Some(0),
|
||||||
|
title: "file0".into()
|
||||||
|
},
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![],
|
||||||
|
parent_id: Some(0),
|
||||||
|
real_torrent_file_id: Some(1),
|
||||||
|
title: "file1".into()
|
||||||
|
},
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![4, 5],
|
||||||
|
parent_id: Some(0),
|
||||||
|
real_torrent_file_id: None,
|
||||||
|
title: "dir0".into()
|
||||||
|
},
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![],
|
||||||
|
parent_id: Some(3),
|
||||||
|
real_torrent_file_id: Some(2),
|
||||||
|
title: "file2".into()
|
||||||
|
},
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![6],
|
||||||
|
parent_id: Some(3),
|
||||||
|
real_torrent_file_id: None,
|
||||||
|
title: "dir1".into()
|
||||||
|
},
|
||||||
|
TorrentFileTreeNode {
|
||||||
|
children: vec![],
|
||||||
|
parent_id: Some(5),
|
||||||
|
real_torrent_file_id: Some(3),
|
||||||
|
title: "file3".into()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_browse_direct_children() {
|
||||||
|
setup_test_logging();
|
||||||
|
|
||||||
|
let t1 = create_torrent(Some("t1"), &["f1"]);
|
||||||
|
let t2 = create_torrent(Some("t2"), &["d1/f2"]);
|
||||||
|
|
||||||
|
fn as_bytes(t: &TorrentMetaV1Owned) -> Bytes {
|
||||||
|
let mut b = Vec::new();
|
||||||
|
bencode_serialize_to_writer(t, &mut b).unwrap();
|
||||||
|
b.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
let td = TempDir::new().unwrap();
|
||||||
|
let session = Session::new_with_opts(
|
||||||
|
td.path().to_owned(),
|
||||||
|
SessionOptions {
|
||||||
|
disable_dht: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
session
|
||||||
|
.add_torrent(
|
||||||
|
AddTorrent::from_bytes(as_bytes(&t1)),
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
paused: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
session
|
||||||
|
.add_torrent(
|
||||||
|
AddTorrent::from_bytes(as_bytes(&t2)),
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
paused: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let adapter = UpnpServerSessionAdapter {
|
||||||
|
session,
|
||||||
|
hostname: "127.0.0.1".into(),
|
||||||
|
port: 9005,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
adapter.browse_direct_children(0),
|
||||||
|
vec![
|
||||||
|
ItemOrContainer::Item(Item {
|
||||||
|
id: encode_id(0, 0),
|
||||||
|
parent_id: None,
|
||||||
|
title: "f1".into(),
|
||||||
|
mime_type: None,
|
||||||
|
url: "http://127.0.0.1:9005/torrents/0/stream/0/f1".into()
|
||||||
|
}),
|
||||||
|
ItemOrContainer::Container(Container {
|
||||||
|
id: encode_id(0, 1),
|
||||||
|
parent_id: None,
|
||||||
|
children_count: None,
|
||||||
|
title: "t2".into()
|
||||||
|
})
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
adapter.browse_direct_children(encode_id(0, 1)),
|
||||||
|
vec![ItemOrContainer::Container(Container {
|
||||||
|
id: encode_id(1, 1),
|
||||||
|
parent_id: Some(encode_id(0, 1)),
|
||||||
|
children_count: Some(1),
|
||||||
|
title: "d1".into()
|
||||||
|
}),]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
adapter.browse_direct_children(encode_id(1, 1)),
|
||||||
|
vec![ItemOrContainer::Item(Item {
|
||||||
|
id: encode_id(2, 1),
|
||||||
|
parent_id: Some(encode_id(1, 1)),
|
||||||
|
title: "f2".into(),
|
||||||
|
mime_type: None,
|
||||||
|
url: "http://127.0.0.1:9005/torrents/1/stream/0/d1/f2".into()
|
||||||
|
})]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encode_id() {
|
||||||
|
for local_id in 0..5 {
|
||||||
|
for torrent_id in 0..5 {
|
||||||
|
let encoded = encode_id(local_id, torrent_id);
|
||||||
|
let (decoded_local_id, decoded_torrent_id) = decode_id(encoded).unwrap();
|
||||||
|
assert_eq!(local_id, decoded_local_id);
|
||||||
|
assert_eq!(torrent_id, decoded_torrent_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ postgres = ["librqbit/postgres"]
|
||||||
librqbit = { path = "../librqbit", default-features = false, features = [
|
librqbit = { path = "../librqbit", default-features = false, features = [
|
||||||
"http-api",
|
"http-api",
|
||||||
"tracing-subscriber-utils",
|
"tracing-subscriber-utils",
|
||||||
|
"upnp-serve-adapter",
|
||||||
], version = "7.0.0-beta.2" }
|
], version = "7.0.0-beta.2" }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
console-subscriber = { version = "0.2", optional = true }
|
console-subscriber = { version = "0.2", optional = true }
|
||||||
|
|
@ -43,6 +44,7 @@ serde_json = "1"
|
||||||
size_format = "1"
|
size_format = "1"
|
||||||
bytes = "1.5.0"
|
bytes = "1.5.0"
|
||||||
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
||||||
|
upnp-serve = { path = "../upnp-serve" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
futures = { version = "0.3" }
|
futures = { version = "0.3" }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::{io, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
|
use std::{io, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::{bail, Context};
|
||||||
use clap::{CommandFactory, Parser, ValueEnum};
|
use clap::{CommandFactory, Parser, ValueEnum};
|
||||||
use clap_complete::Shell;
|
use clap_complete::Shell;
|
||||||
use librqbit::{
|
use librqbit::{
|
||||||
|
|
@ -16,6 +16,7 @@ use librqbit::{
|
||||||
PeerConnectionOptions, Session, SessionOptions, SessionPersistenceConfig, TorrentStatsState,
|
PeerConnectionOptions, Session, SessionOptions, SessionPersistenceConfig, TorrentStatsState,
|
||||||
};
|
};
|
||||||
use size_format::SizeFormatterBinary as SF;
|
use size_format::SizeFormatterBinary as SF;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
use tracing::{error, error_span, info, trace_span, warn};
|
use tracing::{error, error_span, info, trace_span, warn};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
|
|
@ -95,6 +96,15 @@ struct Opts {
|
||||||
#[arg(long = "disable-upnp")]
|
#[arg(long = "disable-upnp")]
|
||||||
disable_upnp: bool,
|
disable_upnp: bool,
|
||||||
|
|
||||||
|
/// If set, will run a UPNP Media server and stream all the torrents through it.
|
||||||
|
/// Should be set to your hostname/IP as seen by your LAN neighbors.
|
||||||
|
#[arg(long = "upnp-server-hostname")]
|
||||||
|
upnp_server_hostname: Option<String>,
|
||||||
|
|
||||||
|
/// UPNP server name that would be displayed on devices in your network.
|
||||||
|
#[arg(long = "upnp-server-friendly-name")]
|
||||||
|
upnp_server_friendly_name: Option<String>,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
subcommand: SubCommand,
|
subcommand: SubCommand,
|
||||||
|
|
||||||
|
|
@ -437,6 +447,28 @@ async fn async_main(opts: Opts) -> anyhow::Result<()> {
|
||||||
trace_span!("stats_printer"),
|
trace_span!("stats_printer"),
|
||||||
stats_printer(session.clone()),
|
stats_printer(session.clone()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let mut upnp_server = {
|
||||||
|
match opts.upnp_server_hostname {
|
||||||
|
Some(hn) => {
|
||||||
|
if opts.http_api_listen_addr.ip().is_loopback() {
|
||||||
|
bail!("cannot enable UPNP server as HTTP API listen addr is localhost. Change --http-api-listen-addr to start with 0.0.0.0");
|
||||||
|
}
|
||||||
|
let server = session
|
||||||
|
.make_upnp_adapter(
|
||||||
|
opts.upnp_server_friendly_name
|
||||||
|
.unwrap_or_else(|| format!("rqbit at {hn}")),
|
||||||
|
hn,
|
||||||
|
opts.http_api_listen_addr.port(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("error starting UPNP server")?;
|
||||||
|
Some(server)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let api = Api::new(
|
let api = Api::new(
|
||||||
session,
|
session,
|
||||||
Some(log_config.rust_log_reload_tx),
|
Some(log_config.rust_log_reload_tx),
|
||||||
|
|
@ -444,10 +476,31 @@ async fn async_main(opts: Opts) -> anyhow::Result<()> {
|
||||||
);
|
);
|
||||||
let http_api = HttpApi::new(api, Some(HttpApiOptions { read_only: false }));
|
let http_api = HttpApi::new(api, Some(HttpApiOptions { read_only: false }));
|
||||||
let http_api_listen_addr = opts.http_api_listen_addr;
|
let http_api_listen_addr = opts.http_api_listen_addr;
|
||||||
http_api
|
|
||||||
.make_http_api_and_run(http_api_listen_addr)
|
info!("starting HTTP API at http://{http_api_listen_addr}");
|
||||||
|
let tcp_listener = TcpListener::bind(http_api_listen_addr)
|
||||||
.await
|
.await
|
||||||
.context("error running HTTP API")
|
.with_context(|| format!("error binding to {http_api_listen_addr}"))?;
|
||||||
|
|
||||||
|
let upnp_router = upnp_server.as_mut().and_then(|s| s.take_router().ok());
|
||||||
|
let http_api_fut = http_api.make_http_api_and_run(tcp_listener, upnp_router);
|
||||||
|
|
||||||
|
let res = match upnp_server {
|
||||||
|
Some(srv) => {
|
||||||
|
let upnp_fut = srv.run_ssdp_forever();
|
||||||
|
|
||||||
|
tokio::pin!(http_api_fut);
|
||||||
|
tokio::pin!(upnp_fut);
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
r = &mut http_api_fut => r,
|
||||||
|
r = &mut upnp_fut => r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => http_api_fut.await,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.context("error running rqbit server")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
SubCommand::Download(download_opts) => {
|
SubCommand::Download(download_opts) => {
|
||||||
|
|
@ -534,10 +587,16 @@ async fn async_main(opts: Opts) -> anyhow::Result<()> {
|
||||||
);
|
);
|
||||||
let http_api = HttpApi::new(api, Some(HttpApiOptions { read_only: true }));
|
let http_api = HttpApi::new(api, Some(HttpApiOptions { read_only: true }));
|
||||||
let http_api_listen_addr = opts.http_api_listen_addr;
|
let http_api_listen_addr = opts.http_api_listen_addr;
|
||||||
|
|
||||||
|
info!("starting HTTP API at http://{http_api_listen_addr}");
|
||||||
|
let listener = tokio::net::TcpListener::bind(opts.http_api_listen_addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("error binding to {http_api_listen_addr}"))?;
|
||||||
|
|
||||||
librqbit_spawn(
|
librqbit_spawn(
|
||||||
"http_api",
|
"http_api",
|
||||||
error_span!("http_api"),
|
error_span!("http_api"),
|
||||||
http_api.make_http_api_and_run(http_api_listen_addr),
|
http_api.make_http_api_and_run(listener, None),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut added = false;
|
let mut added = false;
|
||||||
|
|
|
||||||
31
crates/upnp-serve/Cargo.toml
Normal file
31
crates/upnp-serve/Cargo.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
[package]
|
||||||
|
name = "upnp-serve"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.86"
|
||||||
|
axum = { version = "0.7.5", features = ["tokio"] }
|
||||||
|
tokio = { version = "1.39.3", features = ["full"] }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
bstr = "1.10.0"
|
||||||
|
quick-xml = "0.36.1"
|
||||||
|
http = "1.1.0"
|
||||||
|
httparse = "1.9.4"
|
||||||
|
uuid = { version = "1.10.0", features = ["v4"] }
|
||||||
|
librqbit-upnp = { path = "../upnp" }
|
||||||
|
gethostname = "0.5.0"
|
||||||
|
librqbit-sha1-wrapper = { path = "../sha1w" }
|
||||||
|
librqbit-core = { path = "../librqbit_core" }
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
url = "2.5.2"
|
||||||
|
parking_lot = "0.12.3"
|
||||||
|
tokio-util = "0.7.11"
|
||||||
|
reqwest = { version = "0.12.7", default-features = false }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tracing-subscriber = "0.3.18"
|
||||||
|
tower-http = { version = "0.5", features = ["trace"] }
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "upnp-stub-server"
|
||||||
17
crates/upnp-serve/Makefile
Normal file
17
crates/upnp-serve/Makefile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
all: release-linux-armv7-musl
|
||||||
|
|
||||||
|
@PHONY: release-linux-current-target
|
||||||
|
release-linux-current-target:
|
||||||
|
CC_$(TARGET_SNAKE_CASE)=$(CROSS_COMPILE_PREFIX)-gcc \
|
||||||
|
CXX_$(TARGET_SNAKE_CASE)=$(CROSS_COMPILE_PREFIX)-g++ \
|
||||||
|
AR_$(TARGET_SNAKE_CASE)=$(CROSS_COMPILE_PREFIX)-ar \
|
||||||
|
CARGO_TARGET_$(TARGET_SNAKE_UPPER_CASE)_LINKER=$(CROSS_COMPILE_PREFIX)-gcc \
|
||||||
|
cargo build --target=$(TARGET)
|
||||||
|
|
||||||
|
@PHONY: release-linux-armv7-musl
|
||||||
|
release-linux-armv7-musl:
|
||||||
|
TARGET=armv7-unknown-linux-musleabihf \
|
||||||
|
TARGET_SNAKE_CASE=armv7_unknown_linux_musleabihf \
|
||||||
|
TARGET_SNAKE_UPPER_CASE=ARMV7_UNKNOWN_LINUX_MUSLEABIHF \
|
||||||
|
CROSS_COMPILE_PREFIX=armv7-linux-musleabihf \
|
||||||
|
$(MAKE) release-linux-current-target
|
||||||
74
crates/upnp-serve/examples/upnp-stub-server.rs
Normal file
74
crates/upnp-serve/examples/upnp-stub-server.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
use std::{
|
||||||
|
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
|
||||||
|
str::FromStr,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::routing::get;
|
||||||
|
use mime_guess::Mime;
|
||||||
|
use tracing::{error, info};
|
||||||
|
use upnp_serve::{
|
||||||
|
upnp_types::content_directory::response::{Item, ItemOrContainer},
|
||||||
|
UpnpServer, UpnpServerOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
if std::env::var("RUST_LOG").is_err() {
|
||||||
|
std::env::set_var("RUST_LOG", "trace");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let items: Vec<ItemOrContainer> = vec![ItemOrContainer::Item(Item {
|
||||||
|
title: "Example".to_owned(),
|
||||||
|
mime_type: Some(Mime::from_str("video/x-matroska")?),
|
||||||
|
url: "http://192.168.0.165:3030/torrents/4/stream/0/file.mkv".to_owned(),
|
||||||
|
id: 1,
|
||||||
|
parent_id: Some(0),
|
||||||
|
})];
|
||||||
|
|
||||||
|
const HTTP_PORT: u16 = 9005;
|
||||||
|
const HTTP_PREFIX: &str = "/upnp";
|
||||||
|
|
||||||
|
info!("Creating UpnpServer");
|
||||||
|
let mut server = UpnpServer::new(UpnpServerOptions {
|
||||||
|
friendly_name: "demo upnp server".to_owned(),
|
||||||
|
http_hostname: std::env::var("UPNP_HOSTNAME")
|
||||||
|
.context("you need to set UPNP_HOSTNAME to your IP visible from LAN")?,
|
||||||
|
http_listen_port: HTTP_PORT,
|
||||||
|
http_prefix: HTTP_PREFIX.to_owned(),
|
||||||
|
browse_provider: Box::new(items),
|
||||||
|
cancellation_token: Default::default(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let app = axum::Router::new()
|
||||||
|
.route("/", get(|| async { "hello world" }))
|
||||||
|
.nest(HTTP_PREFIX, server.take_router()?)
|
||||||
|
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||||
|
.into_make_service_with_connect_info::<SocketAddr>();
|
||||||
|
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
let addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, HTTP_PORT);
|
||||||
|
|
||||||
|
info!(?addr, "Binding TcpListener");
|
||||||
|
let listener = TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("error binding to {addr}"))?;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = axum::serve(listener, app).await;
|
||||||
|
error!(error=?res, "error running HTTP server");
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("Running SSDP");
|
||||||
|
server
|
||||||
|
.run_ssdp_forever()
|
||||||
|
.await
|
||||||
|
.context("error running SSDP")?;
|
||||||
|
|
||||||
|
error!("Unreachable");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
9
crates/upnp-serve/src/constants.rs
Normal file
9
crates/upnp-serve/src/constants.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
pub const UPNP_KIND_ROOT_DEVICE: &str = "upnp:rootdevice";
|
||||||
|
pub const UPNP_KIND_MEDIASERVER: &str = "urn:schemas-upnp-org:device:MediaServer:1";
|
||||||
|
|
||||||
|
pub const SOAP_ACTION_CONTENT_DIRECTORY_BROWSE: &[u8] =
|
||||||
|
b"\"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"";
|
||||||
|
pub const SOAP_ACTION_GET_SYSTEM_UPDATE_ID: &[u8] =
|
||||||
|
b"\"urn:schemas-upnp-org:service:ContentDirectory:1#GetSystemUpdateID\"";
|
||||||
|
|
||||||
|
pub const CONTENT_TYPE_XML_UTF8: &str = "text/xml; charset=\"utf-8\"";
|
||||||
214
crates/upnp-serve/src/http_handlers.rs
Normal file
214
crates/upnp-serve/src/http_handlers.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
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 {
|
||||||
|
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 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
|
||||||
|
render_content_directory_browse(
|
||||||
|
state.provider.browse_direct_children(request.object_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.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();
|
||||||
|
trace!(?parts.headers, "subscription request");
|
||||||
|
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 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/scpd_content_directory.xml") }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/scpd/ConnectionManager.xml",
|
||||||
|
get(|| async { include_str!("resources/scpd_connection_manager.xml") }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/control/ContentDirectory",
|
||||||
|
post(generate_content_directory_control_response),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/control/ConnectionManager",
|
||||||
|
post(|| async { (StatusCode::NOT_IMPLEMENTED, "") }),
|
||||||
|
)
|
||||||
|
.route_service("/subscribe", sub_handler.into_service())
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
102
crates/upnp-serve/src/lib.rs
Normal file
102
crates/upnp-serve/src/lib.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
use std::{io::Write, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use gethostname::gethostname;
|
||||||
|
use http_handlers::make_router;
|
||||||
|
use librqbit_sha1_wrapper::ISha1;
|
||||||
|
use ssdp::SsdpRunner;
|
||||||
|
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::debug;
|
||||||
|
use upnp_types::content_directory::ContentDirectoryBrowseProvider;
|
||||||
|
|
||||||
|
mod constants;
|
||||||
|
mod http_handlers;
|
||||||
|
mod ssdp;
|
||||||
|
pub mod state;
|
||||||
|
mod subscriptions;
|
||||||
|
mod templates;
|
||||||
|
pub mod upnp_types;
|
||||||
|
|
||||||
|
pub struct UpnpServerOptions {
|
||||||
|
pub friendly_name: String,
|
||||||
|
pub http_hostname: String,
|
||||||
|
pub http_listen_port: u16,
|
||||||
|
pub http_prefix: String,
|
||||||
|
pub browse_provider: Box<dyn ContentDirectoryBrowseProvider>,
|
||||||
|
pub cancellation_token: CancellationToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UpnpServer {
|
||||||
|
axum_router: Option<axum::Router>,
|
||||||
|
ssdp_runner: SsdpRunner,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_usn(opts: &UpnpServerOptions) -> anyhow::Result<String> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
|
||||||
|
buf.write_all(gethostname().as_encoded_bytes())?;
|
||||||
|
write!(
|
||||||
|
&mut buf,
|
||||||
|
"{}{}{}",
|
||||||
|
opts.friendly_name, opts.http_listen_port, opts.http_prefix
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut sha1 = librqbit_sha1_wrapper::Sha1::new();
|
||||||
|
sha1.update(&buf);
|
||||||
|
|
||||||
|
let hash = sha1.finish();
|
||||||
|
let uuid = uuid::Builder::from_slice(&hash[..16])
|
||||||
|
.context("error generating UUID")?
|
||||||
|
.into_uuid();
|
||||||
|
Ok(format!("uuid:{}", uuid))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpnpServer {
|
||||||
|
pub async fn new(opts: UpnpServerOptions) -> anyhow::Result<Self> {
|
||||||
|
let usn = create_usn(&opts).context("error generating USN")?;
|
||||||
|
|
||||||
|
let description_http_location = {
|
||||||
|
let hostname = &opts.http_hostname;
|
||||||
|
let port = opts.http_listen_port;
|
||||||
|
let http_prefix = &opts.http_prefix;
|
||||||
|
format!("http://{hostname}:{port}{http_prefix}/description.xml")
|
||||||
|
};
|
||||||
|
|
||||||
|
let ssdp_runner = crate::ssdp::SsdpRunner::new(ssdp::SsdpRunnerOptions {
|
||||||
|
usn: usn.clone(),
|
||||||
|
description_http_location,
|
||||||
|
server_string: "Linux/3.4 UPnP/1.0 rqbit/1".to_owned(),
|
||||||
|
notify_interval: Duration::from_secs(60),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("error initializing SsdpRunner")?;
|
||||||
|
|
||||||
|
let router = make_router(
|
||||||
|
opts.friendly_name,
|
||||||
|
opts.http_prefix,
|
||||||
|
usn,
|
||||||
|
opts.browse_provider,
|
||||||
|
opts.cancellation_token,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
axum_router: Some(router),
|
||||||
|
ssdp_runner,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_router(&mut self) -> anyhow::Result<axum::Router> {
|
||||||
|
self.axum_router
|
||||||
|
.take()
|
||||||
|
.context("programming error: router already taken")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_ssdp_forever(&self) -> anyhow::Result<()> {
|
||||||
|
debug!("starting SSDP");
|
||||||
|
self.ssdp_runner
|
||||||
|
.run_forever()
|
||||||
|
.await
|
||||||
|
.context("error running SSDP loop")
|
||||||
|
}
|
||||||
|
}
|
||||||
182
crates/upnp-serve/src/resources/scpd_connection_manager.xml
Normal file
182
crates/upnp-serve/src/resources/scpd_connection_manager.xml
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor>
|
||||||
|
</specVersion>
|
||||||
|
<actionList>
|
||||||
|
<action>
|
||||||
|
<name>GetProtocolInfo</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>Source</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Sink</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
<action>
|
||||||
|
<name>PrepareForConnection</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>RemoteProtocolInfo</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>PeerConnectionManager</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>PeerConnectionID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Direction</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>ConnectionID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>AVTransportID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>RcsID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
<action>
|
||||||
|
<name>ConnectionComplete</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>ConnectionID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
<action>
|
||||||
|
<name>GetCurrentConnectionIDs</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>ConnectionIDs</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
<action>
|
||||||
|
<name>GetCurrentConnectionInfo</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>ConnectionID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>RcsID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>AVTransportID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>ProtocolInfo</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>PeerConnectionManager</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>PeerConnectionID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Direction</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Status</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
</actionList>
|
||||||
|
<serviceStateTable>
|
||||||
|
<stateVariable sendEvents="yes">
|
||||||
|
<name>SourceProtocolInfo</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="yes">
|
||||||
|
<name>SinkProtocolInfo</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="yes">
|
||||||
|
<name>CurrentConnectionIDs</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_ConnectionStatus</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<allowedValueList>
|
||||||
|
<allowedValue>OK</allowedValue>
|
||||||
|
<allowedValue>ContentFormatMismatch</allowedValue>
|
||||||
|
<allowedValue>InsufficientBandwidth</allowedValue>
|
||||||
|
<allowedValue>UnreliableChannel</allowedValue>
|
||||||
|
<allowedValue>Unknown</allowedValue>
|
||||||
|
</allowedValueList>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_ConnectionManager</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_Direction</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<allowedValueList>
|
||||||
|
<allowedValue>Input</allowedValue>
|
||||||
|
<allowedValue>Output</allowedValue>
|
||||||
|
</allowedValueList>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_ProtocolInfo</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_ConnectionID</name>
|
||||||
|
<dataType>i4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_AVTransportID</name>
|
||||||
|
<dataType>i4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_RcsID</name>
|
||||||
|
<dataType>i4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
</serviceStateTable>
|
||||||
|
</scpd>
|
||||||
184
crates/upnp-serve/src/resources/scpd_content_directory.xml
Normal file
184
crates/upnp-serve/src/resources/scpd_content_directory.xml
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor>
|
||||||
|
</specVersion>
|
||||||
|
<actionList>
|
||||||
|
<action>
|
||||||
|
<name>Browse</name>
|
||||||
|
<argumentList>
|
||||||
|
<argument>
|
||||||
|
<name>ObjectID</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>BrowseFlag</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Filter</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>StartingIndex</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>RequestedCount</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>SortCriteria</name>
|
||||||
|
<direction>in</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>Result</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>NumberReturned</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>TotalMatches</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
<argument>
|
||||||
|
<name>UpdateID</name>
|
||||||
|
<direction>out</direction>
|
||||||
|
<relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
|
||||||
|
</argument>
|
||||||
|
</argumentList>
|
||||||
|
</action>
|
||||||
|
</actionList>
|
||||||
|
<serviceStateTable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>SearchCapabilities</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>SortCapabilities</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>SortExtensionCapabilities</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="yes">
|
||||||
|
<name>SystemUpdateID</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="yes">
|
||||||
|
<name>ContainerUpdateIDs</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="yes">
|
||||||
|
<name>TransferIDs</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>FeatureList</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_ObjectID</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_Result</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_SearchCriteria</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_BrowseFlag</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<allowedValueList>
|
||||||
|
<allowedValue>BrowseMetadata</allowedValue>
|
||||||
|
<allowedValue>BrowseDirectChildren</allowedValue>
|
||||||
|
</allowedValueList>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_Filter</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_SortCriteria</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_Index</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_Count</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_UpdateID</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_TransferID</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_TransferStatus</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<allowedValueList>
|
||||||
|
<allowedValue>COMPLETED</allowedValue>
|
||||||
|
<allowedValue>ERROR</allowedValue>
|
||||||
|
<allowedValue>IN_PROGRESS</allowedValue>
|
||||||
|
<allowedValue>STOPPED</allowedValue>
|
||||||
|
</allowedValueList>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_TransferLength</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_TransferTotal</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_TagValueList</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_URI</name>
|
||||||
|
<dataType>uri</dataType>
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_CategoryType</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
<defaultValue />
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_RID</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
<defaultValue />
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_PosSec</name>
|
||||||
|
<dataType>ui4</dataType>
|
||||||
|
<defaultValue />
|
||||||
|
</stateVariable>
|
||||||
|
<stateVariable sendEvents="no">
|
||||||
|
<name>A_ARG_TYPE_Featurelist</name>
|
||||||
|
<dataType>string</dataType>
|
||||||
|
<defaultValue />
|
||||||
|
</stateVariable>
|
||||||
|
</serviceStateTable>
|
||||||
|
</scpd>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<container id="{id}" parentID="{parent_id}" restricted="true" {childCountTag}>
|
||||||
|
<dc:title>{title}</dc:title>
|
||||||
|
<upnp:class>object.container.storageFolder</upnp:class>
|
||||||
|
</container>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
|
||||||
|
<Result><![CDATA[{result}]]></Result>
|
||||||
|
<NumberReturned>{number_returned}</NumberReturned>
|
||||||
|
<TotalMatches>{total_matches}</TotalMatches>
|
||||||
|
<UpdateID>{update_id}</UpdateID>
|
||||||
|
</u:BrowseResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<item id="{id}" parentID="{parent_id}" restricted="true">
|
||||||
|
<dc:title>{title}</dc:title>
|
||||||
|
<upnp:class>{upnp_class}</upnp:class>
|
||||||
|
<res protocolInfo="http-get:*:{mime_type}:DLNA.ORG_OP=01">{url}</res>
|
||||||
|
</item>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<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/">
|
||||||
|
{items}
|
||||||
|
</DIDL-Lite>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetSystemUpdateIDResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
|
||||||
|
<Id>{id}</Id>
|
||||||
|
</u:GetSystemUpdateIDResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
|
||||||
|
<e:property>
|
||||||
|
<SystemUpdateID>{system_update_id}</SystemUpdateID>
|
||||||
|
</e:property>
|
||||||
|
</e:propertyset>
|
||||||
31
crates/upnp-serve/src/resources/templates/root_desc.tmpl.xml
Normal file
31
crates/upnp-serve/src/resources/templates/root_desc.tmpl.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor>
|
||||||
|
</specVersion>
|
||||||
|
<device>
|
||||||
|
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||||
|
<friendlyName>{friendly_name}</friendlyName>
|
||||||
|
<manufacturer>{manufacturer}</manufacturer>
|
||||||
|
<modelName>{model_name}</modelName>
|
||||||
|
<UDN>{unique_id}</UDN>
|
||||||
|
|
||||||
|
<serviceList>
|
||||||
|
<service>
|
||||||
|
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||||
|
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
|
||||||
|
<SCPDURL>{http_prefix}/scpd/ContentDirectory.xml</SCPDURL>
|
||||||
|
<controlURL>{http_prefix}/control/ContentDirectory</controlURL>
|
||||||
|
<eventSubURL>{http_prefix}/subscribe</eventSubURL>
|
||||||
|
</service>
|
||||||
|
<service>
|
||||||
|
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||||
|
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
|
||||||
|
<SCPDURL>{http_prefix}/scpd/ConnectionManager.xml</SCPDURL>
|
||||||
|
<controlURL>{http_prefix}/control/ConnectionManager</controlURL>
|
||||||
|
</service>
|
||||||
|
</serviceList>
|
||||||
|
<presentationURL>/</presentationURL>
|
||||||
|
</device>
|
||||||
|
</root>
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
|
||||||
|
<ObjectID>5</ObjectID>
|
||||||
|
<BrowseFlag>BrowseDirectChildren</BrowseFlag>
|
||||||
|
<Filter>*</Filter>
|
||||||
|
<StartingIndex>0</StartingIndex>
|
||||||
|
<RequestedCount>5000</RequestedCount>
|
||||||
|
<SortCriteria></SortCriteria>
|
||||||
|
</u:Browse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
255
crates/upnp-serve/src/ssdp.rs
Normal file
255
crates/upnp-serve/src/ssdp.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
use std::{
|
||||||
|
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
use bstr::BStr;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
|
use crate::constants::{UPNP_KIND_MEDIASERVER, UPNP_KIND_ROOT_DEVICE};
|
||||||
|
|
||||||
|
const UPNP_PORT: u16 = 1900;
|
||||||
|
const UPNP_BROADCAST_IP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
|
||||||
|
const UPNP_BROADCAST_ADDR: SocketAddrV4 = SocketAddrV4::new(UPNP_BROADCAST_IP, UPNP_PORT);
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SsdpMessage<'a, 'h> {
|
||||||
|
MSearch(SsdpMSearchRequest<'a>),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
OtherRequest(httparse::Request<'h, 'a>),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Response(httparse::Response<'h, 'a>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SsdpMSearchRequest<'a> {
|
||||||
|
pub host: &'a BStr,
|
||||||
|
pub man: &'a BStr,
|
||||||
|
pub st: &'a BStr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SsdpMSearchRequest<'a> {
|
||||||
|
fn matches_media_server(&self) -> bool {
|
||||||
|
if self.host != "239.255.255.250:1900" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.man != "\"ssdp:discover\"" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.st == UPNP_KIND_ROOT_DEVICE || self.st == UPNP_KIND_MEDIASERVER {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_parse_ssdp<'a, 'h>(
|
||||||
|
buf: &'a [u8],
|
||||||
|
headers: &'h mut [httparse::Header<'a>],
|
||||||
|
) -> anyhow::Result<SsdpMessage<'a, 'h>> {
|
||||||
|
if buf.starts_with(b"HTTP/") {
|
||||||
|
let mut resp = httparse::Response::new(headers);
|
||||||
|
resp.parse(buf).context("error parsing response")?;
|
||||||
|
return Ok(SsdpMessage::Response(resp));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut req = httparse::Request::new(headers);
|
||||||
|
req.parse(buf).context("error parsing request")?;
|
||||||
|
|
||||||
|
match req.method {
|
||||||
|
Some("M-SEARCH") => {
|
||||||
|
let mut host = None;
|
||||||
|
let mut man = None;
|
||||||
|
let mut st = None;
|
||||||
|
|
||||||
|
for header in req.headers.iter() {
|
||||||
|
match header.name {
|
||||||
|
"HOST" | "Host" | "host" => host = Some(header.value),
|
||||||
|
"MAN" | "Man" | "man" => man = Some(header.value),
|
||||||
|
"ST" | "St" | "st" => st = Some(header.value),
|
||||||
|
other => trace!(header=?BStr::new(other), "ignoring SSDP header"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (host, man, st) {
|
||||||
|
(Some(host), Some(man), Some(st)) => {
|
||||||
|
return Ok(SsdpMessage::MSearch(SsdpMSearchRequest {
|
||||||
|
host: BStr::new(host),
|
||||||
|
man: BStr::new(man),
|
||||||
|
st: BStr::new(st),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => bail!("not all of host, man and st are set"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Ok(SsdpMessage::OtherRequest(req)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SsdpRunnerOptions {
|
||||||
|
pub usn: String,
|
||||||
|
pub description_http_location: String,
|
||||||
|
pub server_string: String,
|
||||||
|
pub notify_interval: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SsdpRunner {
|
||||||
|
opts: SsdpRunnerOptions,
|
||||||
|
socket: UdpSocket,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SsdpRunner {
|
||||||
|
pub async fn new(opts: SsdpRunnerOptions) -> anyhow::Result<Self> {
|
||||||
|
let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, UPNP_PORT);
|
||||||
|
trace!(addr=?bind_addr, "binding UDP");
|
||||||
|
let socket =
|
||||||
|
tokio::net::UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, UPNP_PORT))
|
||||||
|
.await
|
||||||
|
.context("error binding")?;
|
||||||
|
|
||||||
|
trace!(multiaddr=?UPNP_BROADCAST_IP, interface=?Ipv4Addr::UNSPECIFIED, "joining multicast v4 group");
|
||||||
|
socket
|
||||||
|
.join_multicast_v4(UPNP_BROADCAST_IP, Ipv4Addr::UNSPECIFIED)
|
||||||
|
.context("error joining multicast group")?;
|
||||||
|
|
||||||
|
Ok(Self { opts, socket })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_notify_message(&self, kind: &str) -> String {
|
||||||
|
let usn: &str = &self.opts.usn;
|
||||||
|
let description_http_location = &self.opts.description_http_location;
|
||||||
|
let server: &str = &self.opts.server_string;
|
||||||
|
let bcast_addr = UPNP_BROADCAST_ADDR;
|
||||||
|
format!(
|
||||||
|
"NOTIFY * HTTP/1.1\r
|
||||||
|
Host: {bcast_addr}\r
|
||||||
|
Cache-Control: max-age=75\r
|
||||||
|
Location: {description_http_location}\r
|
||||||
|
NT: {kind}\r
|
||||||
|
NTS: ssdp:alive\r
|
||||||
|
Server: {server}\r
|
||||||
|
USN: {usn}::{kind}\r
|
||||||
|
\r
|
||||||
|
"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_ssdp_discover_response(&self) -> String {
|
||||||
|
let location = &self.opts.description_http_location;
|
||||||
|
let usn = &self.opts.usn;
|
||||||
|
let media_server = UPNP_KIND_MEDIASERVER;
|
||||||
|
let server = &self.opts.server_string;
|
||||||
|
format!(
|
||||||
|
"HTTP/1.1 200 OK\r
|
||||||
|
Cache-Control: max-age=75\r
|
||||||
|
Ext: \r
|
||||||
|
Location: {location}\r
|
||||||
|
Server: {server}\r
|
||||||
|
St: {media_server}\r
|
||||||
|
Usn: {usn}::{media_server}\r
|
||||||
|
Content-Length: 0\r\n\r\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_send_notifies(&self) {
|
||||||
|
for kind in [UPNP_KIND_ROOT_DEVICE, UPNP_KIND_MEDIASERVER] {
|
||||||
|
let msg = self.generate_notify_message(kind);
|
||||||
|
trace!(content=?msg, addr=?UPNP_BROADCAST_ADDR, "sending SSDP NOTIFY");
|
||||||
|
if let Err(e) = self
|
||||||
|
.socket
|
||||||
|
.send_to(msg.as_bytes(), UPNP_BROADCAST_ADDR)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error=?e, "error sending SSDP NOTIFY")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn task_send_notifies_periodically(&self) -> anyhow::Result<()> {
|
||||||
|
let mut interval = tokio::time::interval(self.opts.notify_interval);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
self.try_send_notifies().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_incoming_message(&self, msg: &[u8], addr: SocketAddr) -> anyhow::Result<()> {
|
||||||
|
let mut headers = [httparse::EMPTY_HEADER; 16];
|
||||||
|
trace!(content = ?BStr::new(msg), ?addr, "received message");
|
||||||
|
let parsed = try_parse_ssdp(msg, &mut headers);
|
||||||
|
let msg = match parsed {
|
||||||
|
Ok(SsdpMessage::MSearch(msg)) => msg,
|
||||||
|
Ok(m) => {
|
||||||
|
trace!("ignoring {m:?}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(error=?e, "error parsing SSDP message");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !msg.matches_media_server() {
|
||||||
|
trace!("not a media server request, ignoring");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.generate_ssdp_discover_response();
|
||||||
|
trace!(content = response, ?addr, "sending SSDP discover response");
|
||||||
|
self.socket
|
||||||
|
.send_to(response.as_bytes(), addr)
|
||||||
|
.await
|
||||||
|
.context("error sending")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn task_respond_on_msearches(&self) -> anyhow::Result<()> {
|
||||||
|
let mut buf = vec![0u8; 16184];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (sz, addr) = self
|
||||||
|
.socket
|
||||||
|
.recv_from(&mut buf)
|
||||||
|
.await
|
||||||
|
.context("error receiving")?;
|
||||||
|
let msg = &buf[..sz];
|
||||||
|
if let Err(e) = self.process_incoming_message(msg, addr).await {
|
||||||
|
warn!(error=?e, ?addr, "error processing incoming SSDP message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_msearch(&self) -> anyhow::Result<()> {
|
||||||
|
let msearch_msg = "M-SEARCH * HTTP/1.1\r
|
||||||
|
HOST: 239.255.255.250:1900\r
|
||||||
|
ST: urn:schemas-upnp-org:device:MediaServer:1\r
|
||||||
|
MAN: \"ssdp:discover\"\r
|
||||||
|
MX: 2\r\n\r\n";
|
||||||
|
|
||||||
|
trace!(content = msearch_msg, "multicasting M-SEARCH");
|
||||||
|
|
||||||
|
self.socket
|
||||||
|
.send_to(msearch_msg.as_bytes(), UPNP_BROADCAST_ADDR)
|
||||||
|
.await
|
||||||
|
.context("error sending msearch")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_forever(&self) -> anyhow::Result<()> {
|
||||||
|
// This isn't necessary, but would show that it works.
|
||||||
|
self.send_msearch().await?;
|
||||||
|
|
||||||
|
let t1 = self.task_respond_on_msearches();
|
||||||
|
let t2 = self.task_send_notifies_periodically();
|
||||||
|
|
||||||
|
tokio::pin!(t1);
|
||||||
|
tokio::pin!(t2);
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
r = &mut t1 => r,
|
||||||
|
r = &mut t2 => r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
crates/upnp-serve/src/state.rs
Normal file
78
crates/upnp-serve/src/state.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
use std::{
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use librqbit_core::spawn_utils::spawn_with_cancel;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{error_span, Span};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
subscriptions::Subscriptions, upnp_types::content_directory::ContentDirectoryBrowseProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct UpnpServerStateInner {
|
||||||
|
pub rendered_root_description: Bytes,
|
||||||
|
pub provider: Box<dyn ContentDirectoryBrowseProvider>,
|
||||||
|
pub system_update_id: AtomicU64,
|
||||||
|
pub subscriptions: Subscriptions,
|
||||||
|
|
||||||
|
pub span: Span,
|
||||||
|
pub system_update_bcast_tx: tokio::sync::broadcast::Sender<u64>,
|
||||||
|
pub cancel_token: tokio_util::sync::CancellationToken,
|
||||||
|
_drop_guard: tokio_util::sync::DropGuard,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_system_update_id() -> anyhow::Result<u64> {
|
||||||
|
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpnpServerStateInner {
|
||||||
|
pub fn new(
|
||||||
|
rendered_root_description: Bytes,
|
||||||
|
provider: Box<dyn ContentDirectoryBrowseProvider>,
|
||||||
|
cancellation_token: CancellationToken,
|
||||||
|
) -> anyhow::Result<Arc<Self>> {
|
||||||
|
let cancel_token = cancellation_token.child_token();
|
||||||
|
let drop_guard = cancel_token.clone().drop_guard();
|
||||||
|
let (btx, _) = tokio::sync::broadcast::channel(32);
|
||||||
|
let span = error_span!(parent: None, "upnp-server");
|
||||||
|
let state = Arc::new(Self {
|
||||||
|
rendered_root_description,
|
||||||
|
provider,
|
||||||
|
system_update_id: AtomicU64::new(new_system_update_id()?),
|
||||||
|
subscriptions: Default::default(),
|
||||||
|
system_update_bcast_tx: btx,
|
||||||
|
_drop_guard: drop_guard,
|
||||||
|
span: span.clone(),
|
||||||
|
cancel_token: cancel_token.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
spawn_with_cancel(
|
||||||
|
error_span!(parent: span, "system_update_id_updater"),
|
||||||
|
cancel_token,
|
||||||
|
{
|
||||||
|
let state = Arc::downgrade(&state);
|
||||||
|
async move {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let new_value = new_system_update_id()?;
|
||||||
|
let state = state.upgrade().context("upnp server is dead")?;
|
||||||
|
state.system_update_id.store(new_value, Ordering::Relaxed);
|
||||||
|
let _ = state.system_update_bcast_tx.send(new_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type UnpnServerState = Arc<UpnpServerStateInner>;
|
||||||
200
crates/upnp-serve/src/subscriptions.rs
Normal file
200
crates/upnp-serve/src/subscriptions.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
use crate::state::UpnpServerStateInner;
|
||||||
|
use crate::templates::render_notify_subscription_system_update_id;
|
||||||
|
use anyhow::Context;
|
||||||
|
use http::Method;
|
||||||
|
use librqbit_core::spawn_utils::spawn_with_cancel;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{atomic::Ordering, Arc},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::sync::{broadcast::error::RecvError, Notify};
|
||||||
|
use tracing::{error_span, warn, Instrument};
|
||||||
|
|
||||||
|
pub struct Subscription {
|
||||||
|
pub url: url::Url,
|
||||||
|
pub seq: u64,
|
||||||
|
pub timeout: Duration,
|
||||||
|
pub refresh_notify: Arc<Notify>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Subscriptions {
|
||||||
|
subs: RwLock<HashMap<String, Subscription>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Subscriptions {
|
||||||
|
pub fn add(&self, url: url::Url, timeout: Duration) -> (String, Arc<Notify>) {
|
||||||
|
let sid = format!("uuid:{}", uuid::Uuid::new_v4());
|
||||||
|
let notify = Arc::new(Notify::default());
|
||||||
|
self.subs.write().insert(
|
||||||
|
sid.clone(),
|
||||||
|
Subscription {
|
||||||
|
url,
|
||||||
|
seq: 0,
|
||||||
|
timeout,
|
||||||
|
refresh_notify: notify.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
(sid, notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_timeout(&self, sid: &str, timeout: Duration) -> anyhow::Result<()> {
|
||||||
|
let mut g = self.subs.write();
|
||||||
|
let s = g.get_mut(sid).context("no such subscription")?;
|
||||||
|
s.timeout = timeout;
|
||||||
|
s.refresh_notify.notify_waiters();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_seq(&self, sid: &str) -> anyhow::Result<u64> {
|
||||||
|
let mut g = self.subs.write();
|
||||||
|
let s = g.get_mut(sid).context("no such subscription")?;
|
||||||
|
let id = s.seq;
|
||||||
|
s.seq += 1;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_timeout(&self, sid: &str) -> anyhow::Result<Duration> {
|
||||||
|
let mut g = self.subs.write();
|
||||||
|
let s = g.get_mut(sid).context("no such subscription")?;
|
||||||
|
Ok(s.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, sid: &str) -> anyhow::Result<Subscription> {
|
||||||
|
let mut g = self.subs.write();
|
||||||
|
let s = g.remove(sid).context("no such subscription")?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn notify_subscription_system_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 = render_notify_subscription_system_update_id(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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpnpServerStateInner {
|
||||||
|
pub fn renew_subscription(&self, sid: &str, new_timeout: Duration) -> anyhow::Result<()> {
|
||||||
|
self.subscriptions.update_timeout(sid, new_timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_subscription(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
url: url::Url,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let (sid, refresh_notify) = self.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 mut brx = self.system_update_bcast_tx.subscribe();
|
||||||
|
let state = Arc::downgrade(self);
|
||||||
|
let sid = sid.clone();
|
||||||
|
let url = url.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let system_update_id_notifier = async {
|
||||||
|
loop {
|
||||||
|
let res = brx.recv().await;
|
||||||
|
let state = state.upgrade().context("upnp server dead")?;
|
||||||
|
let seq = state.subscriptions.next_seq(&sid)?;
|
||||||
|
match res {
|
||||||
|
Ok(system_update_id) => {
|
||||||
|
if let Err(e) = notify_subscription_system_update(
|
||||||
|
&url,
|
||||||
|
&sid,
|
||||||
|
seq,
|
||||||
|
system_update_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error=?e, "error updating UPNP subscription");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RecvError::Lagged(by)) => {
|
||||||
|
warn!(by, "UPNP subscription lagged");
|
||||||
|
let seq = state.subscriptions.next_seq(&sid)?;
|
||||||
|
let system_update_id =
|
||||||
|
state.system_update_id.load(Ordering::Relaxed);
|
||||||
|
if let Err(e) = notify_subscription_system_update(
|
||||||
|
&url,
|
||||||
|
&sid,
|
||||||
|
seq,
|
||||||
|
system_update_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error=?e, "error updating UPNP subscription");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RecvError::Closed) => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.instrument(error_span!("system-update-id-notifier"));
|
||||||
|
|
||||||
|
let timeout_notifier = async {
|
||||||
|
let mut timeout = timeout;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = refresh_notify.notified() => {
|
||||||
|
timeout = state.upgrade().context("upnp server dead")?.subscriptions.get_timeout(&sid)?;
|
||||||
|
},
|
||||||
|
_ = tokio::time::sleep(timeout) => {
|
||||||
|
state.upgrade().context("upnp server dead")?.subscriptions.remove(&sid)?;
|
||||||
|
return Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.instrument(error_span!("timeout-killer"));
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
r = system_update_id_notifier => r,
|
||||||
|
r = timeout_notifier => r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
spawn_with_cancel(
|
||||||
|
error_span!(parent: pspan, "subscription-manager", %url),
|
||||||
|
token,
|
||||||
|
subscription_manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
124
crates/upnp-serve/src/templates.rs
Normal file
124
crates/upnp-serve/src/templates.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
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 {
|
||||||
|
let tmpl = include_str!("resources/templates/root_desc.tmpl.xml").trim();
|
||||||
|
|
||||||
|
// This isn't great perf-wise but whatever.
|
||||||
|
tmpl.replace("{friendly_name}", input.friendly_name)
|
||||||
|
.replace("{manufacturer}", input.manufacturer)
|
||||||
|
.replace("{model_name}", input.model_name)
|
||||||
|
.replace("{unique_id}", input.unique_id)
|
||||||
|
.replace("{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 tmpl =
|
||||||
|
include_str!("resources/templates/content_directory_control_browse_item.tmpl.xml")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
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(
|
||||||
|
tmpl.replace("{id}", &item.id.to_string())
|
||||||
|
.replace("{parent_id}", &item.parent_id.unwrap_or(0).to_string())
|
||||||
|
.replace("{mime_type}", &mime)
|
||||||
|
.replace("{url}", &item.url)
|
||||||
|
.replace("{upnp_class}", upnp_class)
|
||||||
|
.replace("{title}", &item.title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn container(item: &Container) -> String {
|
||||||
|
let tmpl = include_str!(
|
||||||
|
"resources/templates/content_directory_control_browse_container.tmpl.xml"
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
tmpl.replace("{id}", &format!("{}", item.id))
|
||||||
|
.replace("{parent_id}", &item.parent_id.unwrap_or(0).to_string())
|
||||||
|
.replace("{title}", &item.title)
|
||||||
|
.replace(
|
||||||
|
"{childCountTag}",
|
||||||
|
&match item.children_count {
|
||||||
|
Some(cc) => format!("childCount=\"{}\"", cc),
|
||||||
|
None => String::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
match item_or_container {
|
||||||
|
ItemOrContainer::Container(c) => Some(container(c)),
|
||||||
|
ItemOrContainer::Item(i) => item(i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Envelope<'a> {
|
||||||
|
result: &'a str,
|
||||||
|
number_returned: usize,
|
||||||
|
total_matches: usize,
|
||||||
|
update_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_content_directory_envelope(envelope: &Envelope<'_>) -> String {
|
||||||
|
let tmpl =
|
||||||
|
include_str!("resources/templates/content_directory_control_browse_envelope.tmpl.xml")
|
||||||
|
.trim();
|
||||||
|
tmpl.replace("{result}", envelope.result)
|
||||||
|
.replace("{number_returned}", &envelope.number_returned.to_string())
|
||||||
|
.replace("{total_matches}", &envelope.total_matches.to_string())
|
||||||
|
.replace("{update_id}", &envelope.update_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_content_directory_browse_result(items: &str) -> String {
|
||||||
|
let tmpl =
|
||||||
|
include_str!("resources/templates/content_directory_control_browse_result.tmpl.xml")
|
||||||
|
.trim();
|
||||||
|
tmpl.replace("{items}", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
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("");
|
||||||
|
|
||||||
|
let result = render_content_directory_browse_result(&all_items);
|
||||||
|
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let update_id = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
render_content_directory_envelope(&Envelope {
|
||||||
|
result: &result,
|
||||||
|
number_returned: total,
|
||||||
|
total_matches: total,
|
||||||
|
update_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_notify_subscription_system_update_id(update_id: u64) -> String {
|
||||||
|
include_str!("resources/templates/notify_subscription.tmpl.xml")
|
||||||
|
.replace("{system_update_id}", &update_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_content_directory_control_get_system_update_id(update_id: u64) -> String {
|
||||||
|
include_str!("resources/templates/content_directory_control_get_system_update_id.tmpl.xml")
|
||||||
|
.replace("{id}", &update_id.to_string())
|
||||||
|
}
|
||||||
81
crates/upnp-serve/src/upnp_types.rs
Normal file
81
crates/upnp-serve/src/upnp_types.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
pub mod content_directory {
|
||||||
|
use response::ItemOrContainer;
|
||||||
|
|
||||||
|
pub mod request {
|
||||||
|
pub struct ContentDirectoryControlRequest {
|
||||||
|
pub object_id: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentDirectoryControlRequest {
|
||||||
|
pub fn parse(s: &str) -> anyhow::Result<Self> {
|
||||||
|
let mut reader = quick_xml::Reader::from_str(s);
|
||||||
|
|
||||||
|
use quick_xml::events::Event::{Eof, Start};
|
||||||
|
|
||||||
|
let mut object_id: Option<usize> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match reader.read_event()? {
|
||||||
|
Eof => break,
|
||||||
|
Start(e) if e.name().as_ref() == b"ObjectID" => {
|
||||||
|
let t = reader.read_text(e.to_end().name())?;
|
||||||
|
object_id = t.trim().parse().ok();
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ContentDirectoryControlRequest {
|
||||||
|
object_id: object_id.unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) -> Vec<ItemOrContainer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentDirectoryBrowseProvider for Vec<ItemOrContainer> {
|
||||||
|
fn browse_direct_children(&self, _parent_id: usize) -> Vec<ItemOrContainer> {
|
||||||
|
self.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::upnp_types::content_directory::request::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ readme = "README.md"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
reqwest = { version = "0.12" }
|
reqwest = { version = "0.12", default-features = false }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde-xml-rs = "0.6.0"
|
serde-xml-rs = "0.6.0"
|
||||||
tokio = { version = "1", features = ["macros"] }
|
tokio = { version = "1", features = ["macros"] }
|
||||||
|
|
|
||||||
142
desktop/src-tauri/Cargo.lock
generated
142
desktop/src-tauri/Cargo.lock
generated
|
|
@ -136,12 +136,6 @@ dependencies = [
|
||||||
"system-deps 6.2.2",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "atomic-waker"
|
|
||||||
version = "1.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
|
|
@ -1376,25 +1370,6 @@ dependencies = [
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h2"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205"
|
|
||||||
dependencies = [
|
|
||||||
"atomic-waker",
|
|
||||||
"bytes",
|
|
||||||
"fnv",
|
|
||||||
"futures-core",
|
|
||||||
"futures-sink",
|
|
||||||
"http 1.1.0",
|
|
||||||
"indexmap 2.4.0",
|
|
||||||
"slab",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
|
|
@ -1532,7 +1507,6 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
|
||||||
"http 1.1.0",
|
"http 1.1.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
|
@ -1544,23 +1518,6 @@ dependencies = [
|
||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hyper-rustls"
|
|
||||||
version = "0.27.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"http 1.1.0",
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tower-service",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
|
|
@ -2984,15 +2941,12 @@ checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
|
||||||
"http 1.1.0",
|
"http 1.1.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
|
||||||
"hyper-tls",
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
|
|
@ -3008,7 +2962,6 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper 1.0.1",
|
"sync_wrapper 1.0.1",
|
||||||
"system-configuration",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
"tokio-socks",
|
"tokio-socks",
|
||||||
|
|
@ -3020,21 +2973,6 @@ dependencies = [
|
||||||
"windows-registry",
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ring"
|
|
||||||
version = "0.17.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"cfg-if",
|
|
||||||
"getrandom 0.2.15",
|
|
||||||
"libc",
|
|
||||||
"spin",
|
|
||||||
"untrusted",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rlimit"
|
name = "rlimit"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|
@ -3092,19 +3030,6 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls"
|
|
||||||
version = "0.23.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044"
|
|
||||||
dependencies = [
|
|
||||||
"once_cell",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"rustls-webpki",
|
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pemfile"
|
name = "rustls-pemfile"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
|
|
@ -3121,17 +3046,6 @@ version = "1.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0"
|
checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-webpki"
|
|
||||||
version = "0.102.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e"
|
|
||||||
dependencies = [
|
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"untrusted",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
|
|
@ -3476,12 +3390,6 @@ dependencies = [
|
||||||
"system-deps 5.0.0",
|
"system-deps 5.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spin"
|
|
||||||
version = "0.9.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
@ -3529,12 +3437,6 @@ version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "subtle"
|
|
||||||
version = "2.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
|
|
@ -3572,27 +3474,6 @@ dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "system-configuration"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "658bc6ee10a9b4fcf576e9b0819d95ec16f4d2c02d39fd83ac1c8789785c4a42"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.6.0",
|
|
||||||
"core-foundation",
|
|
||||||
"system-configuration-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "system-configuration-sys"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "5.0.0"
|
version = "5.0.0"
|
||||||
|
|
@ -4032,17 +3913,6 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-rustls"
|
|
||||||
version = "0.26.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
|
|
||||||
dependencies = [
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-socks"
|
name = "tokio-socks"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
|
|
@ -4316,12 +4186,6 @@ version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
|
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "untrusted"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.2"
|
version = "2.5.2"
|
||||||
|
|
@ -5100,9 +4964,3 @@ dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.75",
|
"syn 2.0.75",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zeroize"
|
|
||||||
version = "1.8.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
|
||||||
|
|
|
||||||
|
|
@ -115,13 +115,20 @@ async fn api_from_config(
|
||||||
);
|
);
|
||||||
|
|
||||||
if !config.http_api.disable {
|
if !config.http_api.disable {
|
||||||
let http_api_task = librqbit::http_api::HttpApi::new(
|
let listen_addr = config.http_api.listen_addr;
|
||||||
api.clone(),
|
let api = api.clone();
|
||||||
Some(librqbit::http_api::HttpApiOptions {
|
let read_only = config.http_api.read_only;
|
||||||
read_only: config.http_api.read_only,
|
let http_api_task = async move {
|
||||||
}),
|
let listener = tokio::net::TcpListener::bind(listen_addr)
|
||||||
)
|
.await
|
||||||
.make_http_api_and_run(config.http_api.listen_addr);
|
.with_context(|| format!("error listening on {}", listen_addr))?;
|
||||||
|
librqbit::http_api::HttpApi::new(
|
||||||
|
api.clone(),
|
||||||
|
Some(librqbit::http_api::HttpApiOptions { read_only }),
|
||||||
|
)
|
||||||
|
.make_http_api_and_run(listener, None)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
|
||||||
session.spawn(error_span!("http_api"), http_api_task);
|
session.spawn(error_span!("http_api"), http_api_task);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue