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 ed3c49a0a..a43249e69 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 0577929fb..587aa248d 100644 --- a/src/models/player.rs +++ b/src/models/player.rs @@ -1,19 +1,25 @@ use std::marker::PhantomData; +use base64::Engine; +use futures::{future, FutureExt, TryFutureExt}; + use crate::constants::{ - CREDITS_THRESHOLD_COEF, VIDEO_HASH_EXTRA_PROP, VIDEO_SIZE_EXTRA_PROP, WATCHED_THRESHOLD_COEF, + BASE64, CREDITS_THRESHOLD_COEF, PLAYER_IGNORE_SEEK_AFTER, 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, 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 stremio_watched_bitfield::WatchedBitField; @@ -24,8 +30,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); @@ -57,6 +61,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, } @@ -99,6 +106,17 @@ pub struct Player { pub ended: bool, #[serde(skip_serializing)] pub paused: Option, + #[serde(skip_serializing)] + pub seek_history: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SeekLog { + /// in milliseconds + pub from: u64, + /// in milliseconds + pub to: u64, } impl UpdateWithCtx for Player { @@ -260,6 +278,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 { @@ -284,7 +313,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) @@ -326,7 +357,18 @@ 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)); + + // 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, + }); + } + 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()); @@ -451,6 +493,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 { @@ -870,6 +936,84 @@ 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 { + // todo: Remove + tracing::info!( + "seek update starting... selected: {}; video_params: {}, series_info: {}, library_item: {}", + selected.is_some(), + video_params.is_some(), + series_info.is_some(), + library_item.is_some() + ); + + let seek_request_effects = match (selected, video_params, series_info, library_item) { + (Some(selected), Some(video_params), Some(series_info), Some(library_item)) => { + // todo: Remove + tracing::info!("seek update continues... is stream a torrent: {}; stream name: {}, video_params.hash: {}", matches!(selected.stream.source, StreamSource::Torrent { .. }), selected.stream.name.is_some(), + video_params.hash.is_some()); + + match ( + &selected.stream.source, + selected.stream.name.as_ref(), + video_params.hash.clone(), + ) { + (StreamSource::Torrent { .. }, Some(stream_name), Some(opensubtitles_hash)) => { + let filename_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(), + filename_hash, + duration: library_item.state.duration, + seek_history: seek_history.to_owned(), + skip_outro: outro.map(|time| vec![time]).unwrap_or_default(), + }; + + // todo: Remove + tracing::info!("SeekLog API request: {seek_log_req:?}"); + + 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 3087dad53..217f230ef 100644 --- a/src/runtime/msg/action.rs +++ b/src/runtime/msg/action.rs @@ -21,7 +21,7 @@ use crate::{ types::{ addon::Descriptor, api::AuthRequest, - library::{LibraryItemId, LibraryItem}, + library::LibraryItemId, profile::Settings as ProfileSettings, resource::{MetaItemId, MetaItemPreview, Video}, }, @@ -143,6 +143,13 @@ pub enum ActionPlayer { PausedChanged { paused: bool, }, + /// Play next video, if there is one, applicable to e.g. + /// movie series and playing the next episode. + 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 episode and 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 6477c24fb..35daabdcd 100644 --- a/src/runtime/msg/internal.rs +++ b/src/runtime/msg/internal.rs @@ -10,7 +10,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}; @@ -104,6 +104,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..53fc6317e 100644 --- a/src/types/api/request.rs +++ b/src/types/api/request.rs @@ -1,7 +1,9 @@ use crate::constants::{API_URL, LINK_API_URL}; +use crate::models::player::SeekLog; 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 +55,28 @@ pub enum APIRequest { auth_key: AuthKey, events: Vec, }, + #[serde(rename_all = "camelCase")] + SeekLog(SeekLogRequest), +} + +#[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, + /// Filename hash + /// + /// base64 encoded SHA-256 hash of the Stream filename. + #[serde(rename = "stHash")] + pub filename_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 +98,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 {