diff --git a/src/deep_links/mod.rs b/src/deep_links/mod.rs index 132de47a8..20828f7ef 100644 --- a/src/deep_links/mod.rs +++ b/src/deep_links/mod.rs @@ -87,7 +87,7 @@ impl From<(&Stream, Option<&Url>, &Settings)> for ExternalPlayerLink { "choose" => Some(OpenPlayerLink { android: Some(format!( "{}#Intent;type=video/any;scheme=https;end", - http_regex.replace(url, "intent://"), + http_regex.replace(url.as_str(), "intent://"), )), ..Default::default() }), @@ -96,27 +96,27 @@ impl From<(&Stream, Option<&Url>, &Settings)> for ExternalPlayerLink { visionos: Some(format!("vlc-x-callback://x-callback-url/stream?url={url}")), android: Some(format!( "{}#Intent;package=org.videolan.vlc;type=video;scheme=https;end", - http_regex.replace(url, "intent://"), + http_regex.replace(url.as_str(), "intent://"), )), ..Default::default() }), "mxplayer" => Some(OpenPlayerLink { android: Some(format!( "{}#Intent;package=com.mxtech.videoplayer.ad;type=video;scheme=https;end", - http_regex.replace(url, "intent://"), + http_regex.replace(url.as_str(), "intent://"), )), ..Default::default() }), "justplayer" => Some(OpenPlayerLink { android: Some(format!( "{}#Intent;package=com.brouken.player;type=video;scheme=https;end", - http_regex.replace(url, "intent://"), + http_regex.replace(url.as_str(), "intent://"), )), ..Default::default() }), "outplayer" => Some(OpenPlayerLink { - ios: Some(format!("{}", http_regex.replace(url, "outplayer://"))), - visionos: Some(format!("{}", http_regex.replace(url, "outplayer://"))), + ios: Some(http_regex.replace(url.as_str(), "outplayer://").to_string()), + visionos: Some(http_regex.replace(url.as_str(), "outplayer://").to_string()), ..Default::default() }), "infuse" => Some(OpenPlayerLink { @@ -166,7 +166,7 @@ impl From<(&Stream, Option<&Url>, &Settings)> for ExternalPlayerLink { }; ExternalPlayerLink { download, - streaming, + streaming: streaming.as_ref().map(ToString::to_string), playlist, file_name, open_player, diff --git a/src/models/streaming_server.rs b/src/models/streaming_server.rs index a9cf5f4a5..5dc04be84 100644 --- a/src/models/streaming_server.rs +++ b/src/models/streaming_server.rs @@ -1,3 +1,11 @@ +use enclose::enclose; +use futures::{FutureExt, TryFutureExt}; +use http::request::Request; +use magnet_url::{Magnet, MagnetError}; +use serde::{Deserialize, Serialize}; +use sha1::{Digest, Sha1}; +use url::Url; + use crate::constants::META_RESOURCE_NAME; use crate::models::common::{eq_update, Loadable}; use crate::models::ctx::{Ctx, CtxError}; @@ -10,16 +18,10 @@ use crate::types::api::SuccessResponse; use crate::types::empty_string_as_null; use crate::types::profile::{AuthKey, Profile}; use crate::types::streaming_server::{ - DeviceInfo, GetHTTPSResponse, NetworkInfo, Settings, SettingsResponse, Statistics, + CreateMagnetRequest, CreateTorrentBlobRequest, DeviceInfo, GetHTTPSResponse, NetworkInfo, + Settings, SettingsResponse, Statistics, StatisticsRequest, TorrentStatisticsRequest, }; -use enclose::enclose; -use futures::{FutureExt, TryFutureExt}; -use http::request::Request; -use magnet_url::{Magnet, MagnetError}; -use serde::{Deserialize, Serialize}; -use sha1::{Digest, Sha1}; -use std::iter; -use url::Url; +use crate::types::torrent::InfoHash; #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -29,13 +31,6 @@ pub struct PlaybackDevice { pub r#type: String, } -#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct StatisticsRequest { - pub info_hash: String, - pub file_idx: u16, -} - #[derive(Clone, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Selected { @@ -53,7 +48,7 @@ pub struct StreamingServer { pub playback_devices: Loadable, EnvError>, pub network_info: Loadable, pub device_info: Loadable, - pub torrent: Option<(String, Loadable)>, + pub torrent: Option<(InfoHash, Loadable)>, /// [`Loadable::Loading`] is used only on the first statistics request. pub statistics: Option>, } @@ -124,12 +119,10 @@ impl UpdateWithCtx for StreamingServer { CreateTorrentArgs::Magnet(magnet), ))) => match parse_magnet(magnet) { Ok((info_hash, announce)) => { - let torrent_effects = eq_update( - &mut self.torrent, - Some((info_hash.to_owned(), Loadable::Loading)), - ); + let torrent_effects = + eq_update(&mut self.torrent, Some((info_hash, Loadable::Loading))); Effects::many(vec![ - create_magnet::(&self.selected.transport_url, &info_hash, &announce), + create_magnet::(&self.selected.transport_url, info_hash, &announce), Effect::Msg(Box::new(Msg::Event(Event::MagnetParsed { magnet: magnet.to_owned(), }))), @@ -160,7 +153,11 @@ impl UpdateWithCtx for StreamingServer { Some((info_hash.to_owned(), Loadable::Loading)), ); Effects::many(vec![ - create_torrent::(&self.selected.transport_url, &info_hash, torrent), + create_torrent_request::( + &self.selected.transport_url, + info_hash, + torrent, + ), Effect::Msg(Box::new(Msg::Event(Event::TorrentParsed { torrent: torrent.to_owned(), }))), @@ -520,55 +517,24 @@ fn set_settings(url: &Url, settings: &Settings) -> Effect { .into() } -fn create_magnet(url: &Url, info_hash: &str, announce: &[String]) -> Effect { - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct PeerSearch { - sources: Vec, - min: u32, - max: u32, - } - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Torrent { - info_hash: String, - } - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Body { - torrent: Torrent, - peer_search: Option, - } - let info_hash = info_hash.to_owned(); - let endpoint = url - .join(&format!("{info_hash}/")) - .expect("url builder failed") - .join("create") - .expect("url builder failed"); - let body = Body { - torrent: Torrent { - info_hash: info_hash.to_owned(), - }, - peer_search: if !announce.is_empty() { - Some(PeerSearch { - sources: iter::once(&format!("dht:{info_hash}")) - .chain(announce.iter()) - .cloned() - .collect(), - min: 40, - max: 200, - }) - } else { - None - }, +pub async fn create_magnet_request( + url: Url, + info_hash: InfoHash, + announce: Vec, +) -> Result { + let request = CreateMagnetRequest { + server_url: url.to_owned(), + info_hash, + announce: announce.to_vec(), }; - let request = Request::post(endpoint.as_str()) - .header(http::header::CONTENT_TYPE, "application/json") - .body(body) - .expect("request builder failed"); + + E::fetch::<_, serde_json::Value>(request.into()).await +} + +fn create_magnet(url: &Url, info_hash: InfoHash, announce: &[String]) -> Effect { EffectFuture::Concurrent( - E::fetch::<_, serde_json::Value>(request) - .map_ok(|_| ()) + create_magnet_request::(url.to_owned(), info_hash, announce.to_vec()) + .map_ok(|_response| ()) .map(enclose!((info_hash) move |result| { Msg::Internal(Internal::StreamingServerCreateTorrentResult( info_hash, result, @@ -579,21 +545,18 @@ fn create_magnet(url: &Url, info_hash: &str, announce: &[Strin .into() } -fn create_torrent(url: &Url, info_hash: &str, torrent: &[u8]) -> Effect { - #[derive(Serialize)] - struct Body { - blob: String, - } - let info_hash = info_hash.to_owned(); - let endpoint = url.join("/create").expect("url builder failed"); - let request = Request::post(endpoint.as_str()) - .header(http::header::CONTENT_TYPE, "application/json") - .body(Body { - blob: hex::encode(torrent), - }) - .expect("request builder failed"); +pub fn create_torrent_request( + url: &Url, + info_hash: InfoHash, + torrent: &[u8], +) -> Effect { + let request = CreateTorrentBlobRequest { + server_url: url.to_owned(), + torrent: torrent.to_vec(), + }; + EffectFuture::Concurrent( - E::fetch::<_, serde_json::Value>(request) + E::fetch::<_, serde_json::Value>(request.into()) .map_ok(|_| ()) .map(enclose!((info_hash) move |result| { Msg::Internal(Internal::StreamingServerCreateTorrentResult( @@ -605,14 +568,18 @@ fn create_torrent(url: &Url, info_hash: &str, torrent: &[u8]) .into() } -fn parse_magnet(magnet: &Url) -> Result<(String, Vec), MagnetError> { +fn parse_magnet(magnet: &Url) -> Result<(InfoHash, Vec), MagnetError> { let magnet = Magnet::new(magnet.as_str())?; let info_hash = magnet.xt.ok_or(MagnetError::NotAMagnetURL)?; + let info_hash = info_hash + .parse() + .map_err(|_err| MagnetError::NotAMagnetURL)?; + let announce = magnet.tr; Ok((info_hash, announce)) } -fn parse_torrent(torrent: &[u8]) -> Result<(String, Vec), serde_bencode::Error> { +fn parse_torrent(torrent: &[u8]) -> Result<(InfoHash, Vec), serde_bencode::Error> { #[derive(Deserialize)] struct TorrentFile { info: serde_bencode::value::Value, @@ -626,7 +593,8 @@ fn parse_torrent(torrent: &[u8]) -> Result<(String, Vec), serde_bencode: let info_bytes = serde_bencode::to_bytes(&torrent_file.info)?; let mut hasher = Sha1::new(); hasher.update(info_bytes); - let info_hash = hex::encode(hasher.finalize()); + let info_hash = InfoHash::new(hasher.finalize().into()); + let mut announce = vec![]; if let Some(announce_entry) = torrent_file.announce { announce.push(announce_entry); @@ -641,26 +609,25 @@ fn parse_torrent(torrent: &[u8]) -> Result<(String, Vec), serde_bencode: } fn get_torrent_statistics(url: &Url, request: &StatisticsRequest) -> Effect { - let statistics_request = request.clone(); - let endpoint = url - .join(&format!( - "/{}/{}/stats.json", - statistics_request.info_hash.clone(), - statistics_request.file_idx - )) - .expect("url builder failed"); - let request = Request::get(endpoint.as_str()) - .header(http::header::CONTENT_TYPE, "application/json") - .body(()) - .expect("request builder failed"); + let fetch_fut = enclose!((url, request) async move { + let request = TorrentStatisticsRequest { + server_url: url, + request, + }; + + let statistics: Option = E::fetch(request.into()).await?; + + Ok(statistics) + }); + // let statistics_request = request.to_owned(); // It's happening when the engine is destroyed for inactivity: // If it was downloaded to 100% and that the stream is paused, then played, // it will create a new engine and return the correct stats EffectFuture::Concurrent( - E::fetch::<_, Option>(request) - .map(enclose!((url) move |result| - Msg::Internal(Internal::StreamingServerStatisticsResult((url, statistics_request), result)) + fetch_fut + .map(enclose!((url, request) move |result| + Msg::Internal(Internal::StreamingServerStatisticsResult((url, request), result)) )) .boxed_env(), ) @@ -736,3 +703,16 @@ fn update_remote_url( _ => eq_update(remote_url, None), } } + +#[cfg(test)] +mod tests { + use magnet_url::Magnet; + + #[test] + fn test_magnet_hash() { + let magnet = Magnet::new("magnet:?xt=urn:btih:0d54e2339706f173ac20f4effb4ad42d9c7a84e9&dn=Halo.S02.1080p.WEBRip.x265.DDP5.1.Atmos-WAR").expect("Should be valid magnet Url"); + + // assert_eq!(magnet.xt) + dbg!(magnet); + } +} diff --git a/src/runtime/msg/action.rs b/src/runtime/msg/action.rs index f2d580bc3..7f36d4fc8 100644 --- a/src/runtime/msg/action.rs +++ b/src/runtime/msg/action.rs @@ -14,7 +14,6 @@ use crate::{ library_with_filters::Selected as LibraryWithFiltersSelected, meta_details::Selected as MetaDetailsSelected, player::{Selected as PlayerSelected, VideoParams}, - streaming_server::StatisticsRequest as StreamingServerStatisticsRequest, }, types::{ addon::Descriptor, @@ -22,7 +21,10 @@ use crate::{ library::LibraryItemId, profile::Settings as ProfileSettings, resource::{MetaItemId, MetaItemPreview, Video}, - streaming_server::Settings as StreamingServerSettings, + streaming_server::{ + Settings as StreamingServerSettings, + StatisticsRequest as StreamingServerStatisticsRequest, + }, }, }; diff --git a/src/runtime/msg/internal.rs b/src/runtime/msg/internal.rs index db64bff2a..ebd380ce0 100644 --- a/src/runtime/msg/internal.rs +++ b/src/runtime/msg/internal.rs @@ -1,10 +1,10 @@ -use crate::models::common::ResourceLoadable; use url::Url; +use crate::models::common::ResourceLoadable; use crate::models::ctx::CtxError; use crate::models::link::LinkError; use crate::models::local_search::Searchable; -use crate::models::streaming_server::{PlaybackDevice, StatisticsRequest}; +use crate::models::streaming_server::PlaybackDevice; use crate::runtime::EnvError; use crate::types::addon::{Descriptor, Manifest, ResourceRequest, ResourceResponse}; use crate::types::api::{ @@ -14,11 +14,14 @@ use crate::types::api::{ }; use crate::types::library::{LibraryBucket, LibraryItem, LibraryItemId}; use crate::types::profile::{Auth, AuthKey, Profile, User}; -use crate::types::resource::{MetaItem, Stream}; use crate::types::streaming_server::{ - DeviceInfo, GetHTTPSResponse, NetworkInfo, SettingsResponse, Statistics, + DeviceInfo, GetHTTPSResponse, NetworkInfo, SettingsResponse, Statistics, StatisticsRequest, }; use crate::types::streams::StreamItemState; +use crate::types::{ + resource::{MetaItem, Stream}, + torrent::InfoHash, +}; pub type CtxStorageResponse = ( Option, @@ -112,7 +115,7 @@ pub enum Internal { /// Result for updating streaming server settings. StreamingServerUpdateSettingsResult(Url, Result<(), EnvError>), /// Result for creating a torrent. - StreamingServerCreateTorrentResult(String, Result<(), EnvError>), + StreamingServerCreateTorrentResult(InfoHash, Result<(), EnvError>), /// Result for playing on device. StreamingServerPlayOnDeviceResult(String, Result<(), EnvError>), // Result for get https endpoint request diff --git a/src/types/mod.rs b/src/types/mod.rs index f7a1215cd..11856864c 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -9,6 +9,7 @@ pub mod resource; pub mod search_history; pub mod streaming_server; pub mod streams; +pub mod torrent; mod query_params_encode; pub use query_params_encode::*; diff --git a/src/types/resource/stream.rs b/src/types/resource/stream.rs index be82802e5..79ed77e88 100644 --- a/src/types/resource/stream.rs +++ b/src/types/resource/stream.rs @@ -1,10 +1,7 @@ -use std::collections::HashMap; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use base64::Engine; use boolinator::Boolinator; -#[cfg(test)] -use derivative::Derivative; use flate2::{ write::{ZlibDecoder, ZlibEncoder}, Compression, @@ -17,8 +14,10 @@ use url::{form_urlencoded, Url}; use stremio_serde_hex::{SerHex, Strict}; -use crate::constants::{BASE64, URI_COMPONENT_ENCODE_SET, YOUTUBE_ADDON_ID_PREFIX}; -use crate::types::resource::Subtitles; +use crate::{ + constants::{BASE64, URI_COMPONENT_ENCODE_SET, YOUTUBE_ADDON_ID_PREFIX}, + types::{resource::Subtitles, streams::StreamSourceTrait}, +}; /// # Examples /// @@ -57,9 +56,10 @@ use crate::types::resource::Subtitles; #[serde_as] #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] -pub struct Stream { +pub struct Stream { + // pub struct Stream { #[serde(flatten)] - pub source: StreamSource, + pub source: S, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, alias = "title", skip_serializing_if = "Option::is_none")] @@ -158,16 +158,8 @@ impl Stream { self.magnet_url().map(|magnet_url| magnet_url.to_string()) } StreamSource::Url { url } => Some(url.to_string()), - StreamSource::Rar { - rar_urls: _, - file_idx: _, - file_must_include: _, - } => None, - StreamSource::Zip { - zip_urls: _, - file_idx: _, - file_must_include: _, - } => None, + // we do not support RAR & Zip at this point! + StreamSource::Rar { .. } | StreamSource::Zip { .. } => None, StreamSource::Torrent { .. } => { self.magnet_url().map(|magnet_url| magnet_url.to_string()) } @@ -188,7 +180,7 @@ impl Stream { }) } - pub fn streaming_url(&self, streaming_server_url: Option<&Url>) -> Option { + pub fn streaming_url(&self, streaming_server_url: Option<&Url>) -> Option { match (&self.source, streaming_server_url) { (StreamSource::Url { url }, streaming_server_url) if url.scheme() != "magnet" => { // If proxy headers are set and streaming server is available, build the proxied streaming url from streaming server url @@ -212,15 +204,17 @@ impl Stream { .iter() .map(|header| ("r", format!("{}:{}", header.0, header.1))), ); + streaming_url.set_path(&format!( "proxy/{query}/{url_path}", query = proxy_query.finish().as_str(), url_path = &url.path().strip_prefix('/').unwrap_or(url.path()), )); + streaming_url.set_query(url.query()); - Some(streaming_url.to_string()) + Some(streaming_url) } - _ => Some(url.to_string()), + _ => Some(url.to_owned()), } } ( @@ -245,20 +239,28 @@ impl Stream { _ => return None, } - let mut query = vec![]; - if !announce.is_empty() { - query.extend(announce.iter().map(|tracker| ("tr", tracker.to_owned()))); - } + // setup query params + { + let mut query_params = url.query_pairs_mut(); - if !file_must_include.is_empty() { - let json_string = serde_json::to_value(file_must_include).ok()?.to_string(); - query.push(("f", json_string)); - } + if !announce.is_empty() { + query_params.extend_pairs( + announce.iter().map(|tracker| ("tr", tracker.to_owned())), + ); + } - url.query_pairs_mut().extend_pairs(query); + if !file_must_include.is_empty() { + query_params.extend_pairs( + file_must_include + .iter() + .map(|file_must_include| ("f", file_must_include.to_owned())), + ); + } + } - Some(url.to_string()) + Some(url) } + // we do not support Rar & Zip at this point (StreamSource::Zip { .. }, Some(_streaming_server_url)) => None, (StreamSource::Rar { .. }, Some(_streaming_server_url)) => None, (StreamSource::YouTube { yt_id }, Some(streaming_server_url)) => { @@ -272,7 +274,7 @@ impl Stream { } _ => return None, }; - Some(url.to_string()) + Some(url) } _ => None, } @@ -422,7 +424,7 @@ impl Stream { /// ``` #[serde_as] #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] -#[cfg_attr(test, derive(Derivative))] +#[cfg_attr(test, derive(derivative::Derivative))] #[cfg_attr(test, derivative(Default))] #[serde(untagged)] pub enum StreamSource { @@ -551,3 +553,36 @@ pub struct StreamBehaviorHints { fn is_default_value(value: &T) -> bool { *value == T::default() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stream_url_source_with_proxy_headers_to_streaming_url() { + let stream_json = serde_json::json!({ + "url": "https://webdav.premiumize.me/%5B%20Torrent911.vc%20%5D%20The.Beekeeper.2024.FRENCH.1080p.WEBRip.x264-RZP.mkv", + "name": "webDav Premiumize", + "description": "[ Torrent911.vc ] The.Beekeeper.2024.FRENCH.1080p.WEBRip.x264-RZP.mkv", + "behaviorHints": { + "notWebReady": true, + "proxyHeaders": { + "request": { + "Authorization": "Basic 'XXXXXXXXXXXXXXXXXXXXXXX='" + } + } + } + + }); + + let stream = serde_json::from_value::(stream_json) + .expect("Should be able to deserialize valid Stream"); + let expected = "http://127.0.0.1:3000/proxy/d=https%3A%2F%2Fwebdav.premiumize.me&h=Authorization%3ABasic+%27XXXXXXXXXXXXXXXXXXXXXXX%3D%27/%5B%20Torrent911.vc%20%5D%20The.Beekeeper.2024.FRENCH.1080p.WEBRip.x264-RZP.mkv".parse::().expect("Valid url"); + assert_eq!( + expected, + stream + .streaming_url(Some(&"http://127.0.0.1:3000/".parse().unwrap())) + .expect("Should be able to generate streaming_url for Stream") + ); + } +} diff --git a/src/types/serde_as_ext.rs b/src/types/serde_as_ext.rs index 8a93d3c89..85b800305 100644 --- a/src/types/serde_as_ext.rs +++ b/src/types/serde_as_ext.rs @@ -1,9 +1,10 @@ -use core::cmp::Ordering; -use core::marker::PhantomData; +use core::{cmp::Ordering, marker::PhantomData}; + +use std::hash::Hash; + use itertools::Itertools; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_with::{DeserializeAs, SerializeAs}; -use std::hash::Hash; +use serde_with::{de::DeserializeAsWrap, DeserializeAs, Same, SerializeAs}; pub trait SortedVecAdapter { type Input; @@ -123,3 +124,100 @@ impl<'de> DeserializeAs<'de, String> for NumberAsString { }) } } + +/// Deserialize an Option from a [`bool`] or the underlying `Option`. +/// For both `true` and `false` values, the Option will be set to `None` +/// +/// # Examples +/// ``` +/// use serde::Deserialize; +/// use serde_with::serde_as; +/// use stremio_core::types::DefaultOnBool; +/// +/// #[serde_as] +/// #[derive(Deserialize, Debug, PartialEq, Eq)] +/// struct MyType { +/// #[serde_as(deserialize_as = "DefaultOnBool")] +/// x: Option, +/// } +/// +/// let json = serde_json::json!({ "x": false }); +/// assert_eq!(MyType { x: None }, serde_json::from_value::(json).expect("Should deserialize")); +/// +/// let json = serde_json::json!({ "x": true }); +/// assert_eq!(MyType { x: None }, serde_json::from_value::(json).expect("Should deserialize")); +/// +/// let json = serde_json::json!({ "x": null }); +/// assert_eq!(MyType { x: None }, serde_json::from_value::(json).expect("Should deserialize")); +/// +/// let json = serde_json::json!({ "x": 32 }); +/// assert_eq!(MyType { x: Some(32) }, serde_json::from_value::(json).expect("Should deserialize")); +/// ``` +#[derive(Copy, Clone, Debug)] +pub struct DefaultOnBool(PhantomData); + +impl<'de, T, U> DeserializeAs<'de, T> for DefaultOnBool +where + U: DeserializeAs<'de, T>, + T: Default, +{ + fn deserialize_as(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok( + match BoolOrValue::>::deserialize(deserializer)? { + BoolOrValue::Bool(_bool) => T::default(), + BoolOrValue::Value(value) => value.into_inner(), + }, + ) + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum BoolOrValue { + Bool(bool), + Value(T), +} +#[cfg(test)] +mod tests { + use super::DefaultOnBool; + + use serde::Deserialize; + use serde_with::serde_as; + + #[serde_as] + #[derive(Deserialize, Debug, PartialEq, Eq)] + struct MyType { + #[serde_as(deserialize_as = "DefaultOnBool")] + pub x: Option, + } + + #[test] + fn test_bool_as_option() { + let json = serde_json::json!({ "x": null }); + assert_eq!( + MyType { x: None }, + serde_json::from_value::(json).expect("Should deserialize") + ); + + let json = serde_json::json!({ "x": false }); + assert_eq!( + MyType { x: None }, + serde_json::from_value::(json).expect("Should deserialize") + ); + + let json = serde_json::json!({ "x": true }); + assert_eq!( + MyType { x: None }, + serde_json::from_value::(json).expect("Should deserialize") + ); + + let json = serde_json::json!({ "x": 32 }); + assert_eq!( + MyType { x: Some(32) }, + serde_json::from_value::(json).expect("Should deserialize") + ); + } +} diff --git a/src/types/streaming_server/mod.rs b/src/types/streaming_server/mod.rs index 4332a0246..d527051de 100644 --- a/src/types/streaming_server/mod.rs +++ b/src/types/streaming_server/mod.rs @@ -1,3 +1,6 @@ +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; + mod device_info; pub use device_info::*; @@ -7,8 +10,51 @@ pub use network_info::*; mod response; pub use response::*; +mod request; +pub use request::*; + mod settings; pub use settings::*; mod statistics; pub use statistics::*; + +use super::resource::SeriesInfo; +use crate::types::{torrent::InfoHash, DefaultOnBool}; + +/// +/// # Examples +/// +/// ``` +/// use stremio_core::types::streaming_server::CreatedTorrent; +/// let json = serde_json::json!({ +/// "torrent": { +/// "infoHash": "df389295484b3059a4726dc6d8a57f71bb5f4c81", +/// }, +/// "peerSearch": { "min": 40, "max": 100, "sources": ["dht:df389295484b3059a4726dc6d8a57f71bb5f4c81", "https://exmaple.com/source"]}, +/// "guessFileIdx": false, +/// }); +/// +/// let created_torrent = serde_json::from_value::(json).expect("Should deserialize"); +/// assert!(created_torrent.guess_file_idx.is_none()); +/// ``` +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatedTorrent { + pub torrent: Torrent, + pub peer_search: PeerSearch, + /// Make the server guess the `fileIdx` based on [`SeriesInfo`]. + /// + /// `stremio-video` sends `false` when no Guessing should be done. + /// If `None, the server will perform no guessing + #[serde_as(deserialize_as = "DefaultOnBool")] + #[serde(default)] + pub guess_file_idx: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Torrent { + pub info_hash: InfoHash, +} diff --git a/src/types/streaming_server/request.rs b/src/types/streaming_server/request.rs new file mode 100644 index 000000000..8f7a20c8c --- /dev/null +++ b/src/types/streaming_server/request.rs @@ -0,0 +1,113 @@ +use http::Request; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::types::{streaming_server::PeerSearch, torrent::InfoHash}; + +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct StatisticsRequest { + pub info_hash: String, + pub file_idx: u16, +} + +pub struct CreateTorrentBlobRequest { + pub server_url: Url, + pub torrent: Vec, +} + +impl From for Request { + fn from(val: CreateTorrentBlobRequest) -> Self { + let endpoint = val.server_url.join("/create").expect("url builder failed"); + + Request::post(endpoint.as_str()) + .header(http::header::CONTENT_TYPE, "application/json") + .body(CreateTorrentBlobBody { + blob: hex::encode(val.torrent), + }) + .expect("request builder failed") + } +} +#[derive(Serialize)] +pub struct CreateTorrentBlobBody { + pub blob: String, +} + +pub struct CreateMagnetRequest { + pub server_url: Url, + pub info_hash: InfoHash, + pub announce: Vec, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMagnetBody { + pub torrent: CreateMagnetTorrent, + pub peer_search: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMagnetTorrent { + pub info_hash: InfoHash, +} + +impl From for Request { + fn from(val: CreateMagnetRequest) -> Self { + let info_hash = val.info_hash; + + let body = CreateMagnetBody { + torrent: CreateMagnetTorrent { + info_hash: val.info_hash.to_owned(), + }, + peer_search: if !val.announce.is_empty() { + Some(PeerSearch::new(40, 200, info_hash, val.announce)) + } else { + None + }, + }; + + let info_hash = info_hash.to_owned(); + let endpoint = val + .server_url + .join(&format!("{info_hash}/create")) + .expect("url builder failed"); + + Request::post(endpoint.as_str()) + .header(http::header::CONTENT_TYPE, "application/json") + .body(body) + .expect("request builder should never fail!") + } +} + +/// Filename request to the server. +/// +/// `{streaming_sever_url}/{info_hash_url_encoded}/{file_idx_url_encoded}/stats.json` +/// +/// +/// Example: `http://127.0.0.1:11470/6d0cdb871b81477d00f53f78529028994b364877/7/stats.json` +pub struct TorrentStatisticsRequest { + pub server_url: Url, + pub request: StatisticsRequest, +} +impl From for Request<()> { + fn from(val: TorrentStatisticsRequest) -> Self { + let info_hash_encoded = url::form_urlencoded::Serializer::new(String::new()) + .append_key_only(&val.request.info_hash.to_string()) + .finish(); + let file_idx_encoded = url::form_urlencoded::Serializer::new(String::new()) + .append_key_only(&val.request.file_idx.to_string()) + .finish(); + + let uri = val + .server_url + .join(&format!( + "{info_hash_encoded}/{file_idx_encoded}/stats.json" + )) + .expect("Should always be valid url!"); + + Request::get(uri.as_str()) + .header(http::header::CONTENT_TYPE, "application/json") + .body(()) + .expect("Always valid request!") + } +} diff --git a/src/types/streaming_server/response.rs b/src/types/streaming_server/response.rs index 87212edac..f34c979c4 100644 --- a/src/types/streaming_server/response.rs +++ b/src/types/streaming_server/response.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use url::Url; -use crate::types::streaming_server::Settings; +use crate::types::{streaming_server::Settings, torrent::InfoHash}; #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -18,6 +18,12 @@ pub struct GetHTTPSResponse { pub port: u16, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OpensubtitlesParamsResponse { + pub hash: InfoHash, + pub size: u64, +} + #[cfg(test)] mod test { use super::*; diff --git a/src/types/streaming_server/statistics.rs b/src/types/streaming_server/statistics.rs index 00fad53cb..354f18784 100644 --- a/src/types/streaming_server/statistics.rs +++ b/src/types/streaming_server/statistics.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use url::Url; +use crate::types::torrent::InfoHash; + #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct File { @@ -25,6 +27,21 @@ pub struct PeerSearch { pub sources: Vec, } +impl PeerSearch { + pub fn new(min: u64, max: u64, info_hash: InfoHash, additional_sources: Vec) -> Self { + Self { + max, + min, + sources: { + let mut sources = vec![format!("dht:{info_hash}")]; + sources.extend(additional_sources.into_iter().map(|url| url.to_string())); + + sources + }, + } + } +} + #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct SwarmCap { @@ -76,6 +93,7 @@ pub struct Statistics { pub connection_tries: u64, pub peer_search_running: bool, pub stream_len: u64, + /// Filename for torrent pub stream_name: String, pub stream_progress: f64, pub swarm_connections: u64, diff --git a/src/types/streams/converted_source.rs b/src/types/streams/converted_source.rs new file mode 100644 index 000000000..dec2a5db1 --- /dev/null +++ b/src/types/streams/converted_source.rs @@ -0,0 +1,26 @@ +use url::Url; + +use crate::types::{resource::StreamSource, torrent::InfoHash}; + +/// Trait which defines the StreamSource state data structures in Core. +pub trait StreamSourceTrait: sealed::Sealed {} +/// only we should be able to define which data structures are StreamSource states! +mod sealed { + pub trait Sealed {} +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum ConvertedStreamSource { + Url(Url), + Torrent { + url: Url, + info_hash: InfoHash, + file_idx: Option, + announce: Vec, + }, +} +impl StreamSourceTrait for ConvertedStreamSource {} +impl sealed::Sealed for ConvertedStreamSource {} + +impl sealed::Sealed for StreamSource {} +impl StreamSourceTrait for StreamSource {} diff --git a/src/types/streams/mod.rs b/src/types/streams/mod.rs index 0eae18df0..a8c51f05d 100644 --- a/src/types/streams/mod.rs +++ b/src/types/streams/mod.rs @@ -3,3 +3,6 @@ pub use streams_item::*; mod streams_bucket; pub use streams_bucket::*; + +mod converted_source; +pub use converted_source::*; diff --git a/src/types/torrent.rs b/src/types/torrent.rs new file mode 100644 index 000000000..640d65d1a --- /dev/null +++ b/src/types/torrent.rs @@ -0,0 +1,47 @@ +use core::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use stremio_serde_hex::{SerHex, Strict}; + +/// +/// # Examples +/// ``` +/// use stremio_core::types::torrent::InfoHash; +/// +/// let info_hash = "df389295484b3059a4726dc6d8a57f71bb5f4c81" +/// .parse::() +/// .unwrap(); +/// +/// dbg!(info_hash); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct InfoHash(#[serde(with = "SerHex::")] [u8; 20]); + +impl InfoHash { + pub fn new(info_hash: [u8; 20]) -> Self { + Self(info_hash) + } + + pub fn as_array(&self) -> [u8; 20] { + self.0 + } +} + +impl FromStr for InfoHash { + type Err = hex::FromHexError; + + fn from_str(s: &str) -> Result { + let mut array = [0_u8; 20]; + hex::decode_to_slice(s, &mut array)?; + + Ok(Self(array)) + } +} + +impl fmt::Display for InfoHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.0)) + } +} diff --git a/src/unit_tests/deep_links/stream_deep_links.rs b/src/unit_tests/deep_links/stream_deep_links.rs index d98e6325f..f4f0e3c61 100644 --- a/src/unit_tests/deep_links/stream_deep_links.rs +++ b/src/unit_tests/deep_links/stream_deep_links.rs @@ -10,9 +10,9 @@ use std::str::FromStr; use url::Url; const MAGNET_STR_URL: &str = "magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c"; -const HTTP_STR_URL: &str = "http://domain.root/path"; +const HTTP_STR_URL: &str = "http://domain.root/some/path"; const HTTP_WITH_QUERY_STR_URL: &str = "http://domain.root/some/path?param=some&foo=bar"; -const BASE64_HTTP_URL: &str = "data:application/octet-stream;charset=utf-8;base64,I0VYVE0zVQojRVhUSU5GOjAKaHR0cDovL2RvbWFpbi5yb290L3BhdGg="; +const BASE64_HTTP_URL: &str = "data:application/octet-stream;charset=utf-8;base64,I0VYVE0zVQojRVhUSU5GOjAKaHR0cDovL2RvbWFpbi5yb290L3NvbWUvcGF0aA=="; const STREAMING_SERVER_URL: &str = "http://127.0.0.1:11470"; const YT_ID: &str = "aqz-KE-bpKQ"; @@ -55,9 +55,8 @@ fn stream_deep_links_http() { let settings = Settings::default(); let sdl = StreamDeepLinks::from((&stream, &streaming_server_url, &settings)); assert_eq!( - sdl.player, - "stremio:///player/eAEBIQDe%2F3sidXJsIjoiaHR0cDovL2RvbWFpbi5yb290L3BhdGgifcEEC6w%3D" - .to_string() + &sdl.player, + "stremio:///player/eAEBJgDZ%2F3sidXJsIjoiaHR0cDovL2RvbWFpbi5yb290L3NvbWUvcGF0aCJ9AYANjw%3D%3D", ); assert_eq!( sdl.external_player.playlist, @@ -97,12 +96,13 @@ fn stream_deep_links_http_with_request_headers() { let streaming_server_url = Some(Url::parse(STREAMING_SERVER_URL).unwrap()); let settings = Settings::default(); let sdl = StreamDeepLinks::from((&stream, &streaming_server_url, &settings)); - assert_eq!(sdl.player, "stremio:///player/eAEBawCU%2F3sidXJsIjoiaHR0cDovL2RvbWFpbi5yb290L3BhdGgiLCJiZWhhdmlvckhpbnRzIjp7InByb3h5SGVhZGVycyI6eyJyZXF1ZXN0Ijp7IkF1dGhvcml6YXRpb24iOiJteSt0b2tlbiJ9fX19DNkm%2FA%3D%3D".to_string()); + assert_eq!(sdl.player, "stremio:///player/eAEBcACP%2F3sidXJsIjoiaHR0cDovL2RvbWFpbi5yb290L3NvbWUvcGF0aCIsImJlaGF2aW9ySGludHMiOnsicHJveHlIZWFkZXJzIjp7InJlcXVlc3QiOnsiQXV0aG9yaXphdGlvbiI6Im15K3Rva2VuIn19fX3Y5Cjf".to_string()); assert_eq!( sdl.external_player.streaming, Some(format!( "{}/proxy/{}", - STREAMING_SERVER_URL, "d=http%3A%2F%2Fdomain.root&h=Authorization%3Amy%2Btoken/path" + STREAMING_SERVER_URL, + "d=http%3A%2F%2Fdomain.root&h=Authorization%3Amy%2Btoken/some/path", )) ); } @@ -143,7 +143,7 @@ fn stream_deep_links_http_with_request_response_headers_and_query_params() { Some(format!( "{}/proxy/{}", STREAMING_SERVER_URL, - "d=http%3A%2F%2Fdomain.root&h=Authorization%3Amy%2Btoken&r=Content-Type%3Aapplication%2Fxml/some/path?param=some&foo=bar" + "d=http%3A%2F%2Fdomain.root&h=Authorization%3Amy%2Btoken&r=Content-Type%3Aapplication%2Fxml/some/path?param=some&foo=bar", )) ); } @@ -291,7 +291,7 @@ fn stream_deep_links_external() { let streaming_server_url = Some(Url::parse(STREAMING_SERVER_URL).unwrap()); let settings = Settings::default(); let sdl = StreamDeepLinks::from((&stream, &streaming_server_url, &settings)); - assert_eq!(sdl.player, "stremio:///player/eAEBKQDW%2F3siZXh0ZXJuYWxVcmwiOiJodHRwOi8vZG9tYWluLnJvb3QvcGF0aCJ9OoEO7w%3D%3D".to_string()); + assert_eq!(&sdl.player, "stremio:///player/eAEBLgDR%2F3siZXh0ZXJuYWxVcmwiOiJodHRwOi8vZG9tYWluLnJvb3Qvc29tZS9wYXRoIn2LPRDS"); assert_eq!( sdl.external_player.web, Some(Url::from_str(HTTP_STR_URL).unwrap()), @@ -349,7 +349,7 @@ fn stream_deep_links_player_frame() { let streaming_server_url = Some(Url::parse(STREAMING_SERVER_URL).unwrap()); let settings = Settings::default(); let sdl = StreamDeepLinks::from((&stream, &streaming_server_url, &settings)); - assert_eq!(sdl.player, "stremio:///player/eAEBLADT%2F3sicGxheWVyRnJhbWVVcmwiOiJodHRwOi8vZG9tYWluLnJvb3QvcGF0aCJ9abUQBA%3D%3D".to_string()); + assert_eq!(&sdl.player, "stremio:///player/eAEBMQDO%2F3sicGxheWVyRnJhbWVVcmwiOiJodHRwOi8vZG9tYWluLnJvb3Qvc29tZS9wYXRoIn2%2F2hHn"); assert_eq!(sdl.external_player.playlist, None); assert_eq!(sdl.external_player.file_name, None); } diff --git a/stremio-core-web/src/model/serialize_streaming_server.rs b/stremio-core-web/src/model/serialize_streaming_server.rs index c28cb3912..24b149c84 100644 --- a/stremio-core-web/src/model/serialize_streaming_server.rs +++ b/stremio-core-web/src/model/serialize_streaming_server.rs @@ -11,6 +11,8 @@ use url::Url; use wasm_bindgen::JsValue; mod model { + use stremio_core::types::torrent::InfoHash; + use super::*; type TorrentLoadable<'a> = Loadable<(&'a ResourcePath, MetaItemDeepLinks), &'a EnvError>; #[derive(Serialize)] @@ -23,7 +25,7 @@ mod model { pub playback_devices: &'a Loadable, EnvError>, pub network_info: &'a Loadable, pub device_info: &'a Loadable, - pub torrent: Option<(&'a String, TorrentLoadable<'a>)>, + pub torrent: Option<(&'a InfoHash, TorrentLoadable<'a>)>, pub statistics: Option<&'a Loadable>, } }