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
|
|
@ -1,6 +1,6 @@
|
|||
use anyhow::Context;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::extract::{ConnectInfo, Path, Query, Request, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post};
|
||||
use bencode::AsDisplay;
|
||||
|
|
@ -16,7 +16,8 @@ use std::net::SocketAddr;
|
|||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncSeekExt;
|
||||
use tracing::{debug, info, trace};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{debug, error_span, trace};
|
||||
|
||||
use axum::Router;
|
||||
|
||||
|
|
@ -52,7 +53,11 @@ impl HttpApi {
|
|||
/// Run the HTTP server forever on the given address.
|
||||
/// If read_only is passed, no state-modifying methods will be exposed.
|
||||
#[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;
|
||||
|
||||
async fn api_root() -> impl IntoResponse {
|
||||
|
|
@ -558,22 +563,33 @@ impl HttpApi {
|
|||
.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
|
||||
.layer(cors_layer)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
.into_make_service();
|
||||
|
||||
info!(%addr, "starting HTTP server");
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
.layer(
|
||||
tower_http::trace::TraceLayer::new_for_http().make_span_with(|req: &Request| {
|
||||
let method = req.method();
|
||||
let uri = req.uri();
|
||||
if let Some(ConnectInfo(addr)) =
|
||||
req.extensions().get::<ConnectInfo<SocketAddr>>()
|
||||
{
|
||||
error_span!("request", %method, %uri, %addr)
|
||||
} else {
|
||||
error_span!("request", %method, %uri)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_make_service_with_connect_info::<SocketAddr>();
|
||||
|
||||
async move {
|
||||
let listener = TcpListener::bind(&addr)
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.with_context(|| format!("error binding to {addr}"))?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
.context("error running HTTP API")
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ mod torrent_state;
|
|||
#[cfg(feature = "tracing-subscriber-utils")]
|
||||
pub mod tracing_subscriber_config_utils;
|
||||
mod type_aliases;
|
||||
#[cfg(all(feature = "http-api", feature = "upnp-serve-adapter"))]
|
||||
pub mod upnp_server_adapter;
|
||||
|
||||
pub use api::Api;
|
||||
pub use api_error::ApiError;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ use std::{
|
|||
};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use axum::{response::IntoResponse, routing::get, Router};
|
||||
use librqbit_core::Id20;
|
||||
use parking_lot::RwLock;
|
||||
use rand::{thread_rng, Rng, RngCore, SeedableRng};
|
||||
|
|
@ -96,7 +95,9 @@ impl TestPeerMetadata {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http-api")]
|
||||
async fn debug_server() -> anyhow::Result<()> {
|
||||
use axum::{response::IntoResponse, routing::get, Router};
|
||||
async fn backtraces() -> impl IntoResponse {
|
||||
#[cfg(feature = "async-bt")]
|
||||
{
|
||||
|
|
@ -127,6 +128,11 @@ async fn debug_server() -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "http-api"))]
|
||||
async fn debug_server() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue