diff --git a/Makefile b/Makefile index 4a1f9dcc8..eb5b73cf6 100644 --- a/Makefile +++ b/Makefile @@ -63,13 +63,23 @@ docker-build-armv7: clean: rm -rf target +CARGO_RELEASE_PROFILE ?= release-github + @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 --profile release-github --target=$(TARGET) --features=openssl-vendored + cargo build --profile $(CARGO_RELEASE_PROFILE) --target=$(TARGET) --features=openssl-vendored + +@PHONY: debug-linux-docker-x86_64 +debug-linux-docker-x86_64: + CARGO_RELEASE_PROFILE=dev \ + $(MAKE) release-linux-x86_64 && \ + cp target/x86_64-unknown-linux-musl/debug/rqbit target/cross/linux/amd64/ && \ + docker build -t ikatson/rqbit:tmp-debug -f docker/Dockerfile --platform linux/amd64 target/cross && \ + docker push ikatson/rqbit:tmp-debug @PHONY: release-linux-x86_64 release-linux-x86_64: diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index e6f701d9f..8d336f82b 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -346,6 +346,29 @@ impl HttpApi { let mut output_headers = HeaderMap::new(); output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); + const DLNA_TRANSFER_MODE: &str = "transferMode.dlna.org"; + const DLNA_GET_CONTENT_FEATURES: &str = "getcontentFeatures.dlna.org"; + const DLNA_CONTENT_FEATURES: &str = "contentFeatures.dlna.org"; + + if headers + .get(DLNA_TRANSFER_MODE) + .map(|v| matches!(v.as_bytes(), b"Streaming" | b"streaming")) + .unwrap_or(false) + { + output_headers.insert(DLNA_TRANSFER_MODE, HeaderValue::from_static("Streaming")); + } + + if headers + .get(DLNA_GET_CONTENT_FEATURES) + .map(|v| v.as_bytes() == b"1") + .unwrap_or(false) + { + output_headers.insert( + DLNA_CONTENT_FEATURES, + HeaderValue::from_static("DLNA.ORG_OP=01"), + ); + } + if let Ok(mime) = state.torrent_file_mime_type(idx, file_id) { output_headers.insert( http::header::CONTENT_TYPE, diff --git a/crates/librqbit/src/upnp_server_adapter.rs b/crates/librqbit/src/upnp_server_adapter.rs index e6878b771..7a2e27840 100644 --- a/crates/librqbit/src/upnp_server_adapter.rs +++ b/crates/librqbit/src/upnp_server_adapter.rs @@ -30,6 +30,7 @@ use upnp_serve::{ #[derive(Debug, PartialEq, Eq)] struct TorrentFileTreeNode { title: String, + // must be set for all nodes except the root node. parent_id: Option, children: Vec, @@ -61,7 +62,8 @@ impl TorrentFileTreeNode { 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 fi = &torrent.shared().file_infos[fid]; + let filename = &fi.relative_filename; // Torrent path joined with "/" let last_url_bit = torrent .shared() @@ -70,11 +72,16 @@ impl TorrentFileTreeNode { .ok() .and_then(|mut it| it.nth(fid)) .and_then(|(fi, _)| fi.to_vec().ok()) - .map(|components| components.join("/")) + .map(|components| { + components + .into_iter() + .map(|c| urlencoding::encode(&c).into_owned()) + .join("/") + }) .unwrap_or_else(|| self.title.clone()); ItemOrContainer::Item(Item { id: encoded_id, - parent_id: encoded_parent_id, + parent_id: encoded_parent_id.unwrap_or_default(), title: self.title.clone(), mime_type: mime_guess::from_path(filename).first(), url: format!( @@ -85,11 +92,12 @@ impl TorrentFileTreeNode { fid, last_url_bit ), + size: fi.len, }) } None => ItemOrContainer::Container(Container { id: encoded_id, - parent_id: encoded_parent_id, + parent_id: Some(encoded_parent_id.unwrap_or_default()), title: self.title.clone(), children_count: Some(self.children.len()), }), @@ -213,18 +221,15 @@ impl UpnpServerSessionAdapter { // 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}", - hostname, self.port - ); - Some(ItemOrContainer::Item(Item { - id: upnp_id, - parent_id: None, - title, - mime_type, - url, - })) + Some( + TorrentFileTreeNode { + title, + parent_id: None, + children: vec![], + real_torrent_file_id: Some(0), + } + .as_item_or_container(0, hostname, t, self), + ) } else { let title = t .shared() @@ -238,7 +243,7 @@ impl UpnpServerSessionAdapter { // Create a folder Some(ItemOrContainer::Container(Container { id: upnp_id, - parent_id: None, + parent_id: Some(0), title, children_count: None, })) @@ -246,26 +251,34 @@ impl UpnpServerSessionAdapter { }) .collect_vec() } -} -impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter { - fn browse_direct_children( + fn build_impl( &self, - parent_id: usize, + object_id: usize, http_hostname: &str, + metadata: bool, ) -> Vec { - if parent_id == 0 { - return self.build_root(http_hostname); + if object_id == 0 { + let root = self.build_root(http_hostname); + if metadata { + return vec![ItemOrContainer::Container(Container { + id: 0, + parent_id: None, + children_count: Some(root.len()), + title: "root".to_owned(), + })]; + } + return root; } - let (node_id, torrent_id) = match decode_id(parent_id) { + let (node_id, torrent_id) = match decode_id(object_id) { Ok((node_id, torrent_id)) => (node_id, torrent_id), Err(_) => { - debug!(id=?parent_id, "invalid id"); + debug!(id=?object_id, "invalid id"); return vec![]; } }; - trace!(parent_id, node_id, torrent_id); + trace!(object_id, node_id, torrent_id); let torrent = match self.session.get(torrent_id.into()) { Some(t) => t, @@ -278,7 +291,7 @@ impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter { 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"); + warn!(object_id, error=?e, "error building torrent file tree"); return vec![]; } }; @@ -295,7 +308,7 @@ impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter { let mut result = Vec::new(); - if node.real_torrent_file_id.is_some() { + if node.real_torrent_file_id.is_some() || metadata { result.push(node.as_item_or_container(node_id, http_hostname, &torrent, self)) } else { for (child_node_id, child_node) in node @@ -316,6 +329,20 @@ impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter { } } +impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter { + fn browse_direct_children( + &self, + object_id: usize, + http_hostname: &str, + ) -> Vec { + self.build_impl(object_id, http_hostname, false) + } + + fn browse_metadata(&self, object_id: usize, http_hostname: &str) -> Vec { + self.build_impl(object_id, http_hostname, true) + } +} + impl Session { pub async fn make_upnp_adapter( self: &Arc, @@ -496,7 +523,7 @@ mod tests { } #[tokio::test] - async fn test_browse_direct_children() { + async fn test_browse() { setup_test_logging(); let t1 = create_torrent(Some("t1"), &["f1"]); @@ -545,25 +572,58 @@ mod tests { port: 9005, }; + assert_eq!( + adapter.browse_metadata(0, "127.0.0.1"), + vec![ItemOrContainer::Container(Container { + id: 0, + parent_id: None, + children_count: Some(2), + title: "root".into() + })] + ); + assert_eq!( adapter.browse_direct_children(0, "127.0.0.1"), vec![ ItemOrContainer::Item(Item { id: encode_id(0, 0), - parent_id: None, + parent_id: 0, title: "f1".into(), mime_type: None, - url: "http://127.0.0.1:9005/torrents/0/stream/0/f1".into() + url: "http://127.0.0.1:9005/torrents/0/stream/0/f1".into(), + size: 1, }), ItemOrContainer::Container(Container { id: encode_id(0, 1), - parent_id: None, + parent_id: Some(0), children_count: None, title: "t2".into() }) ] ); + assert_eq!( + adapter.browse_metadata(encode_id(0, 0), "127.0.0.1"), + vec![ItemOrContainer::Item(Item { + id: encode_id(0, 0), + parent_id: 0, + title: "f1".into(), + mime_type: None, + url: "http://127.0.0.1:9005/torrents/0/stream/0/f1".into(), + size: 1, + })] + ); + + assert_eq!( + adapter.browse_metadata(encode_id(0, 1), "127.0.0.1"), + vec![ItemOrContainer::Container(Container { + id: encode_id(0, 1), + parent_id: Some(0), + children_count: Some(1), + title: "t2".into() + })] + ); + assert_eq!( adapter.browse_direct_children(encode_id(0, 1), "127.0.0.1"), vec![ItemOrContainer::Container(Container { @@ -574,14 +634,37 @@ mod tests { }),] ); + assert_eq!( + adapter.browse_metadata(encode_id(1, 1), "127.0.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), "127.0.0.1"), vec![ItemOrContainer::Item(Item { id: encode_id(2, 1), - parent_id: Some(encode_id(1, 1)), + parent_id: encode_id(1, 1), + title: "f2".into(), + mime_type: None, + url: "http://127.0.0.1:9005/torrents/1/stream/0/d1/f2".into(), + size: 1, + })] + ); + + assert_eq!( + adapter.browse_metadata(encode_id(2, 1), "127.0.0.1"), + vec![ItemOrContainer::Item(Item { + id: encode_id(2, 1), + parent_id: encode_id(1, 1), title: "f2".into(), mime_type: None, - url: "http://127.0.0.1:9005/torrents/1/stream/0/d1/f2".into() + url: "http://127.0.0.1:9005/torrents/1/stream/0/d1/f2".into(), + size: 1, })] ); } diff --git a/crates/upnp-serve/examples/upnp-stub-server.rs b/crates/upnp-serve/examples/upnp-stub-server.rs index efb768bbf..f70dd0523 100644 --- a/crates/upnp-serve/examples/upnp-stub-server.rs +++ b/crates/upnp-serve/examples/upnp-stub-server.rs @@ -6,12 +6,28 @@ use std::{ use anyhow::Context; use axum::routing::get; use librqbit_upnp_serve::{ - services::content_directory::browse::response::{Item, ItemOrContainer}, + services::content_directory::{ + browse::response::{Item, ItemOrContainer}, + ContentDirectoryBrowseProvider, + }, UpnpServer, UpnpServerOptions, }; use mime_guess::Mime; use tracing::{error, info}; +struct VecWrap(Vec); + +impl ContentDirectoryBrowseProvider for VecWrap { + fn browse_direct_children(&self, _parent_id: usize, _http_host: &str) -> Vec { + self.0.clone() + } + + fn browse_metadata(&self, _object_id: usize, _http_hostname: &str) -> Vec { + // TODO. Remove the vec provider from core code. + vec![] + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { if std::env::var("RUST_LOG").is_err() { @@ -20,13 +36,14 @@ async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); - let items: Vec = vec![ItemOrContainer::Item(Item { + let items = VecWrap(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), - })]; + parent_id: 0, + size: 1, + })]); const HTTP_PORT: u16 = 9005; const HTTP_PREFIX: &str = "/upnp"; diff --git a/crates/upnp-serve/src/resources/templates/content_directory/control/browse/item.tmpl.xml b/crates/upnp-serve/src/resources/templates/content_directory/control/browse/item.tmpl.xml index 1780249fb..1c2ab6897 100644 --- a/crates/upnp-serve/src/resources/templates/content_directory/control/browse/item.tmpl.xml +++ b/crates/upnp-serve/src/resources/templates/content_directory/control/browse/item.tmpl.xml @@ -1,5 +1,5 @@ {title} {upnp_class} - {url} + {url} diff --git a/crates/upnp-serve/src/resources/templates/content_directory/control/browse/response.tmpl.xml b/crates/upnp-serve/src/resources/templates/content_directory/control/browse/response.tmpl.xml index 09bb4de6c..6527db081 100644 --- a/crates/upnp-serve/src/resources/templates/content_directory/control/browse/response.tmpl.xml +++ b/crates/upnp-serve/src/resources/templates/content_directory/control/browse/response.tmpl.xml @@ -2,11 +2,7 @@ - - {items} -]]> + {items_encoded} {number_returned} {total_matches} {update_id} diff --git a/crates/upnp-serve/src/services/content_directory.rs b/crates/upnp-serve/src/services/content_directory.rs index 8fa9571d9..1e7f4ce4f 100644 --- a/crates/upnp-serve/src/services/content_directory.rs +++ b/crates/upnp-serve/src/services/content_directory.rs @@ -62,6 +62,8 @@ pub mod browse { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Container { pub id: usize, + // Parent id is None only for the root container. + // The only way to see the root container is BrowseMetadata on ObjectID=0 pub parent_id: Option, pub children_count: Option, pub title: String, @@ -70,10 +72,11 @@ pub mod browse { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Item { pub id: usize, - pub parent_id: Option, + pub parent_id: usize, pub title: String, pub mime_type: Option, pub url: String, + pub size: u64, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -97,11 +100,12 @@ pub mod browse { "../resources/templates/content_directory/control/browse/item.tmpl.xml" ), id = item.id, - parent_id = item.parent_id.unwrap_or(0), + parent_id = item.parent_id, mime_type = mime, url = item.url, upnp_class = upnp_class, - title = item.title + title = item.title, + size = item.size )) } @@ -115,7 +119,7 @@ pub mod browse { "../resources/templates/content_directory/control/browse/container.tmpl.xml" ), id = item.id, - parent_id = item.parent_id.unwrap_or(0), + parent_id = item.parent_id.map(|p| p as isize).unwrap_or(-1), title = item.title, childCountTag = child_count_tag ) @@ -135,11 +139,24 @@ pub mod browse { } fn render_response(envelope: &Envelope<'_>) -> String { + let items_encoded = format!( + r#" + {items} + "#, + items = envelope.items + ); + + // This COULD have been done with CDATA, but some Samsung TVs don't like that, they want + // escaped XML instead. + let items_encoded = quick_xml::escape::escape(items_encoded.as_ref()); + format!( include_str!( "../resources/templates/content_directory/control/browse/response.tmpl.xml" ), - items = envelope.items, + items_encoded = items_encoded, number_returned = envelope.number_returned, total_matches = envelope.total_matches, update_id = envelope.update_id @@ -293,7 +310,15 @@ pub(crate) async fn http_handler( ), ) .into_response(), - BrowseFlag::BrowseMetadata => StatusCode::NOT_IMPLEMENTED.into_response(), + BrowseFlag::BrowseMetadata => ( + [(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)], + browse::response::render( + state + .provider + .browse_metadata(request.object_id, http_hostname), + ), + ) + .into_response(), } } SOAP_ACTION_GET_SYSTEM_UPDATE_ID => { @@ -314,12 +339,7 @@ pub(crate) async fn http_handler( pub trait ContentDirectoryBrowseProvider: Send + Sync { fn browse_direct_children(&self, parent_id: usize, http_hostname: &str) -> Vec; -} - -impl ContentDirectoryBrowseProvider for Vec { - fn browse_direct_children(&self, _parent_id: usize, _http_host: &str) -> Vec { - self.clone() - } + fn browse_metadata(&self, object_id: usize, http_hostname: &str) -> Vec; } #[cfg(test)]