diff --git a/Cargo.toml b/Cargo.toml index 110695e82..025fa9c57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,8 @@ chrono = { version = "0.4", features = ["serde"] } semver = { version = "1", features = ["serde"] } base64 = "0.21" sha1 = "0.10" +sha2 = "0.10" + either = "1.6" enclose = "1.1" derivative = "2.2" diff --git a/src/constants.rs b/src/constants.rs index 0b9403ddb..488b61f9f 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -51,6 +51,9 @@ pub const URI_COMPONENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC .remove(b'(') .remove(b')'); +/// In milliseconds +pub const PLAYER_IGNORE_SEEK_AFTER: u64 = 600_000; + pub static BASE64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::STANDARD; diff --git a/src/models/player.rs b/src/models/player.rs index f92cd2418..e502511f8 100644 --- a/src/models/player.rs +++ b/src/models/player.rs @@ -1,20 +1,28 @@ use std::marker::PhantomData; +use base64::Engine; +use futures::{future, FutureExt, TryFutureExt}; + use crate::constants::{ - CREDITS_THRESHOLD_COEF, META_RESOURCE_NAME, VIDEO_FILENAME_EXTRA_PROP, VIDEO_HASH_EXTRA_PROP, - VIDEO_SIZE_EXTRA_PROP, WATCHED_THRESHOLD_COEF, + BASE64, CREDITS_THRESHOLD_COEF, META_RESOURCE_NAME, PLAYER_IGNORE_SEEK_AFTER, + VIDEO_FILENAME_EXTRA_PROP, VIDEO_HASH_EXTRA_PROP, VIDEO_SIZE_EXTRA_PROP, + WATCHED_THRESHOLD_COEF, }; use crate::models::common::{ - eq_update, resource_update, resources_update_with_vector_content, Loadable, ResourceAction, - ResourceLoadable, ResourcesAction, + eq_update, resource_update, resource_update_with_vector_content, + resources_update_with_vector_content, Loadable, ResourceAction, ResourceLoadable, + ResourcesAction, }; -use crate::models::ctx::Ctx; +use crate::models::ctx::{Ctx, CtxError}; use crate::runtime::msg::{Action, ActionLoad, ActionPlayer, Event, Internal, Msg}; -use crate::runtime::{Effects, Env, UpdateWithCtx}; +use crate::runtime::{Effect, EffectFuture, Effects, Env, EnvFutureExt, UpdateWithCtx}; use crate::types::addon::{AggrRequest, Descriptor, ExtraExt, ResourcePath, ResourceRequest}; +use crate::types::api::{ + fetch_api, APIRequest, APIResult, SeekLog, SeekLogRequest, SuccessResponse, +}; use crate::types::library::{LibraryBucket, LibraryItem}; use crate::types::profile::Settings as ProfileSettings; -use crate::types::resource::{MetaItem, SeriesInfo, Stream, Subtitles, Video}; +use crate::types::resource::{MetaItem, SeriesInfo, Stream, StreamSource, Subtitles, Video}; use crate::types::streams::{StreamItemState, StreamsBucket, StreamsItemKey}; use stremio_watched_bitfield::WatchedBitField; @@ -26,8 +34,6 @@ use serde::{Deserialize, Serialize}; use lazy_static::lazy_static; -use super::common::resource_update_with_vector_content; - lazy_static! { /// The duration that must have passed in order for a library item to be updated. pub static ref PUSH_TO_LIBRARY_EVERY: Duration = Duration::seconds(30); @@ -59,6 +65,9 @@ pub struct AnalyticsContext { #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct VideoParams { + /// Opensubtitles hash usually retrieved from a streaming server endpoint. + /// + /// It's used for requesting subtitles from Opensubtitles. pub hash: Option, pub size: Option, pub filename: Option, @@ -103,6 +112,8 @@ pub struct Player { pub ended: bool, #[serde(skip_serializing)] pub paused: Option, + #[serde(skip_serializing)] + pub seek_history: Vec, } impl UpdateWithCtx for Player { @@ -245,6 +256,17 @@ impl UpdateWithCtx for Player { } else { Effects::none().unchanged() }; + let seek_history_effects = seek_update::( + self.selected.as_ref(), + self.video_params.as_ref(), + self.series_info.as_ref(), + self.library_item.as_ref(), + &mut self.seek_history, + // we do not have information whether the user is currently + // skipping the outro by Unloading the player. + None, + ); + let switch_to_next_video_effects = switch_to_next_video(&mut self.library_item, &self.next_video); let push_to_library_effects = match &self.library_item { @@ -270,7 +292,9 @@ impl UpdateWithCtx for Player { self.loaded = false; self.ended = false; self.paused = None; - switch_to_next_video_effects + + seek_history_effects + .join(switch_to_next_video_effects) .join(push_to_library_effects) .join(selected_effects) .join(video_params_effects) @@ -327,7 +351,8 @@ impl UpdateWithCtx for Player { Some(library_item), ) => { let seeking = library_item.state.time_offset.abs_diff(*time) > 1000; - // library_item.state.last_watched = Some(E::now() - chrono::Duration::days(1)); + + // if we've selected a new video (like the next episode) library_item.state.last_watched = Some(E::now()); if library_item.state.video_id != Some(video_id.to_owned()) { library_item.state.video_id = Some(video_id.to_owned()); @@ -338,6 +363,18 @@ impl UpdateWithCtx for Player { library_item.state.time_watched = 0; library_item.state.flagged_watched = 0; } else { + // else we have added to the currently selected video/stream + // seek logging + if seeking + && library_item.r#type == "series" + && time < &PLAYER_IGNORE_SEEK_AFTER + { + self.seek_history.push(SeekLog { + from: library_item.state.time_offset, + to: *time, + }); + } + let time_watched = 1000.min(time.saturating_sub(library_item.state.time_offset)); library_item.state.time_watched = @@ -452,6 +489,30 @@ impl UpdateWithCtx for Player { }; trakt_event_effects.join(update_library_item_effects) } + Msg::Action(Action::Player(ActionPlayer::NextVideo)) => { + let seek_history_effects = seek_update::( + self.selected.as_ref(), + self.video_params.as_ref(), + self.series_info.as_ref(), + self.library_item.as_ref(), + &mut self.seek_history, + // use the current LibraryItem time offset as the outro time. + self.library_item + .as_ref() + .map(|library_item| library_item.state.time_offset), + ); + + // Load will actually take care of loading the next video + + seek_history_effects.join( + Effects::msg(Msg::Event(Event::PlayerNextVideo { + context: self.analytics_context.as_ref().cloned().unwrap_or_default(), + is_binge_enabled: ctx.profile.settings.binge_watching, + is_playing_next_video: self.next_video.is_some(), + })) + .unchanged(), + ) + } Msg::Action(Action::Player(ActionPlayer::Ended)) if self.selected.is_some() => { self.ended = true; Effects::msg(Msg::Event(Event::PlayerEnded { @@ -912,6 +973,75 @@ fn subtitles_update( } } +fn seek_update( + selected: Option<&Selected>, + video_params: Option<&VideoParams>, + series_info: Option<&SeriesInfo>, + library_item: Option<&LibraryItem>, + seek_history: &mut Vec, + outro: Option, +) -> Effects { + let has_seeks_or_outro = !seek_history.is_empty() || matches!(outro, Some(outro) if outro > 0); + let seek_request_effects = match ( + has_seeks_or_outro, + selected, + video_params, + series_info, + library_item, + ) { + (true, Some(selected), Some(video_params), Some(series_info), Some(library_item)) => { + match ( + &selected.stream.source, + selected.stream.name.as_ref(), + video_params.hash.clone(), + ) { + (StreamSource::Torrent { .. }, Some(stream_name), Some(opensubtitles_hash)) => { + let stream_name_hash = { + use sha2::Digest; + let mut sha256 = sha2::Sha256::new(); + sha256.update(stream_name); + let sha256_encoded = sha256.finalize(); + + BASE64.encode(sha256_encoded) + }; + + let seek_log_req = SeekLogRequest { + opensubtitles_hash, + item_id: library_item.id.to_owned(), + series_info: series_info.to_owned(), + stream_name_hash, + duration: library_item.state.duration, + seek_history: seek_history.to_owned(), + skip_outro: outro.map(|time| vec![time]).unwrap_or_default(), + }; + + Effects::one(push_seek_to_api::(seek_log_req)).unchanged() + } + _ => Effects::none().unchanged(), + } + } + _ => Effects::none().unchanged(), + }; + + seek_request_effects.join(eq_update(seek_history, vec![])) +} + +fn push_seek_to_api(seek_log_req: SeekLogRequest) -> Effect { + let api_request = APIRequest::SeekLog(seek_log_req.clone()); + + EffectFuture::Concurrent( + fetch_api::(&api_request) + .map_err(CtxError::from) + .and_then(|result| match result { + APIResult::Ok { result } => future::ok(result), + APIResult::Err { error } => future::err(CtxError::from(error)), + }) + .map(move |result| Msg::Internal(Internal::SeekLogsResult(seek_log_req, result))) + .boxed_env(), + ) + .into() +} + #[cfg(test)] mod test { use chrono::{TimeZone, Utc}; diff --git a/src/runtime/msg/action.rs b/src/runtime/msg/action.rs index 9834b0629..9eb6869d2 100644 --- a/src/runtime/msg/action.rs +++ b/src/runtime/msg/action.rs @@ -147,6 +147,13 @@ pub enum ActionPlayer { PausedChanged { paused: bool, }, + /// User has clicked on the next video button. + NextVideo, + /// Video player has ended. + /// + /// 2 scenarios are possible: + /// - We've watched a movie to the last second + /// - We've watched a movie series to the last second Ended, } diff --git a/src/runtime/msg/event.rs b/src/runtime/msg/event.rs index c7712afcf..f7aff4cbc 100644 --- a/src/runtime/msg/event.rs +++ b/src/runtime/msg/event.rs @@ -20,6 +20,11 @@ pub enum Event { PlayerStopped { context: PlayerAnalyticsContext, }, + PlayerNextVideo { + context: PlayerAnalyticsContext, + is_binge_enabled: bool, + is_playing_next_video: bool, + }, PlayerEnded { context: PlayerAnalyticsContext, is_binge_enabled: bool, diff --git a/src/runtime/msg/internal.rs b/src/runtime/msg/internal.rs index 8ea5a0fb1..a86fe7d97 100644 --- a/src/runtime/msg/internal.rs +++ b/src/runtime/msg/internal.rs @@ -11,7 +11,7 @@ use crate::runtime::EnvError; use crate::types::addon::{Descriptor, Manifest, ResourceRequest, ResourceResponse}; use crate::types::api::{ APIRequest, AuthRequest, DataExportResponse, DatastoreRequest, LinkCodeResponse, - LinkDataResponse, + LinkDataResponse, SeekLogRequest, SuccessResponse, }; use crate::types::library::{LibraryBucket, LibraryItem, LibraryItemId}; use crate::types::profile::{Auth, AuthKey, Profile, User}; @@ -112,6 +112,10 @@ pub enum Internal { NotificationsRequestResult(ResourceRequest, Box>), /// Result for requesting a `dataExport` of user data. DataExportResult(AuthKey, Result), + /// Result for submitting SeekLogs request for a played stream. + /// + /// Applicable only to movie series and torrents. + SeekLogsResult(SeekLogRequest, Result), /// The result of querying the data for LocalSearch LoadLocalSearchResult(Url, Result, EnvError>), } diff --git a/src/types/api/request.rs b/src/types/api/request.rs index 250ac15ab..5796a9626 100644 --- a/src/types/api/request.rs +++ b/src/types/api/request.rs @@ -2,6 +2,7 @@ use crate::constants::{API_URL, LINK_API_URL}; use crate::types::addon::Descriptor; use crate::types::library::LibraryItem; use crate::types::profile::{AuthKey, GDPRConsent, User}; +use crate::types::resource::SeriesInfo; #[cfg(test)] use derivative::Derivative; use http::Method; @@ -53,6 +54,37 @@ pub enum APIRequest { auth_key: AuthKey, events: Vec, }, + #[serde(rename_all = "camelCase")] + SeekLog(SeekLogRequest), +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SeekLog { + /// in milliseconds + pub from: u64, + /// in milliseconds + pub to: u64, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SeekLogRequest { + /// Opensubtitles hash returned by the server + #[serde(rename = "osId")] + pub opensubtitles_hash: String, + pub item_id: String, + #[serde(flatten)] + pub series_info: SeriesInfo, + /// Stream name hash + /// + /// base64 encoded SHA-256 hash of the Stream file name. + #[serde(rename = "stHash")] + pub stream_name_hash: String, + pub duration: u64, + pub seek_history: Vec, + /// The time (in milliseconds) when the user decided to play the next video/episode + pub skip_outro: Vec, } impl FetchRequestParams for APIRequest { @@ -74,6 +106,7 @@ impl FetchRequestParams for APIRequest { APIRequest::SaveUser { .. } => "saveUser".to_owned(), APIRequest::DataExport { .. } => "dataExport".to_owned(), APIRequest::Events { .. } => "events".to_owned(), + APIRequest::SeekLog { .. } => "seekLog".to_owned(), } } fn query(&self) -> Option {