diff --git a/src/models/ctx/ctx.rs b/src/models/ctx/ctx.rs index 8820fe575..30b42af25 100644 --- a/src/models/ctx/ctx.rs +++ b/src/models/ctx/ctx.rs @@ -1,7 +1,7 @@ use crate::{ constants::LIBRARY_COLLECTION_NAME, models::{ - common::{DescriptorLoadable, Loadable, ResourceLoadable}, + common::{eq_update, DescriptorLoadable, Loadable, ResourceLoadable}, ctx::{ update_events, update_library, update_notifications, update_profile, update_search_history, update_streaming_server_urls, update_streams, @@ -15,12 +15,13 @@ use crate::{ types::{ api::{ fetch_api, APIRequest, APIResult, AuthRequest, AuthResponse, CollectionResponse, - DatastoreCommand, DatastoreRequest, LibraryItemsResponse, SuccessResponse, + DatastoreCommand, DatastoreRequest, LibraryItemsResponse, RefreshTraktToken, + SuccessResponse, }, events::{DismissedEventsBucket, Events}, library::LibraryBucket, notifications::NotificationsBucket, - profile::{Auth, AuthKey, Profile}, + profile::{Auth, AuthKey, Profile, User}, resource::MetaItem, search_history::SearchHistoryBucket, server_urls::ServerUrlsBucket, @@ -28,6 +29,7 @@ use crate::{ }, }; +use chrono::{DateTime, Utc}; #[cfg(test)] use derivative::Derivative; use enclose::enclose; @@ -75,6 +77,15 @@ pub struct Ctx { pub notification_catalogs: Vec>>, pub events: Events, + #[serde(skip)] + pub refresh_trakt: Option, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +pub struct RefreshTrakt { + pub request: RefreshTraktToken, + pub last_requested: DateTime, + pub response: Loadable, } impl Ctx { @@ -103,6 +114,7 @@ impl Ctx { modal: Loadable::Loading, notification: Loadable::Loading, }, + refresh_trakt: None, } } } @@ -125,8 +137,13 @@ impl Update for Ctx { } _ => Effects::none().unchanged(), }; - let profile_effects = - update_profile::(&mut self.profile, &mut self.streams, &self.status, msg); + let profile_effects = update_profile::( + &mut self.profile, + &mut self.streams, + &mut self.refresh_trakt, + &self.status, + msg, + ); let library_effects = update_library::(&mut self.library, &self.profile, &self.status, msg); let streams_effects = update_streams::(&mut self.streams, &self.status, msg); @@ -145,6 +162,8 @@ impl Update for Ctx { &self.status, msg, ); + let refresh_trakt_effects = eq_update(&mut self.refresh_trakt, None); + let notifications_effects = update_notifications::( &mut self.notifications, &mut self.notification_catalogs, @@ -164,11 +183,17 @@ impl Update for Ctx { .join(search_history_effects) .join(events_effects) .join(trakt_addon_effects) + .join(refresh_trakt_effects) .join(notifications_effects) } Msg::Internal(Internal::CtxAuthResult(auth_request, result)) => { - let profile_effects = - update_profile::(&mut self.profile, &mut self.streams, &self.status, msg); + let profile_effects = update_profile::( + &mut self.profile, + &mut self.streams, + &mut self.refresh_trakt, + &self.status, + msg, + ); let library_effects = update_library::(&mut self.library, &self.profile, &self.status, msg); let trakt_addon_effects = update_trakt_addon::( @@ -267,8 +292,13 @@ impl Update for Ctx { .join(ctx_effects) } _ => { - let profile_effects = - update_profile::(&mut self.profile, &mut self.streams, &self.status, msg); + let profile_effects = update_profile::( + &mut self.profile, + &mut self.streams, + &mut self.refresh_trakt, + &self.status, + msg, + ); let library_effects = update_library::(&mut self.library, &self.profile, &self.status, msg); let streams_effects = update_streams::(&mut self.streams, &self.status, msg); diff --git a/src/models/ctx/update_profile.rs b/src/models/ctx/update_profile.rs index 146867f60..fd8c3aaba 100644 --- a/src/models/ctx/update_profile.rs +++ b/src/models/ctx/update_profile.rs @@ -4,19 +4,27 @@ use enclose::enclose; use futures::{future, FutureExt, TryFutureExt}; use crate::constants::{OFFICIAL_ADDONS, PROFILE_STORAGE_KEY}; -use crate::models::ctx::{CtxError, CtxStatus, OtherError}; +use crate::models::common::eq_update; +use crate::models::{ + common::Loadable, + ctx::{CtxError, CtxStatus, OtherError}, +}; use crate::runtime::msg::{Action, ActionCtx, CtxAuthResponse, Event, Internal, Msg}; -use crate::runtime::{Effect, EffectFuture, Effects, Env, EnvFutureExt}; +use crate::runtime::{Effect, EffectFuture, Effects, Env, EnvError, EnvFutureExt}; use crate::types::addon::Descriptor; use crate::types::api::{ - fetch_api, APIError, APIRequest, APIResult, CollectionResponse, SuccessResponse, + fetch_api, APIError, APIRequest, APIResult, CollectionResponse, RefreshTraktToken, + SuccessResponse, }; use crate::types::profile::{Auth, AuthKey, Password, Profile, Settings, User}; use crate::types::streams::StreamsBucket; +use super::RefreshTrakt; + pub fn update_profile( profile: &mut Profile, streams: &mut StreamsBucket, + refresh_trakt: &mut Option, status: &CtxStatus, msg: &Msg, ) -> Effects { @@ -48,8 +56,8 @@ pub fn update_profile( })) .unchanged(), }, - Msg::Action(Action::Ctx(ActionCtx::PullUserFromAPI)) => match profile.auth_key() { - Some(auth_key) => Effects::one(pull_user_from_api::(auth_key)).unchanged(), + Msg::Action(Action::Ctx(ActionCtx::PullUserFromAPI)) => match profile.auth.as_ref() { + Some(auth) => Effects::one(pull_user_from_api::(&auth.key)).unchanged(), _ => Effects::msg(Msg::Event(Event::Error { error: CtxError::from(OtherError::UserNotLoggedIn), source: Box::new(Event::UserPulledFromAPI { uid: profile.uid() }), @@ -364,12 +372,73 @@ pub fn update_profile( Msg::Internal(Internal::UserAPIResult(APIRequest::GetUser { auth_key }, result)) if profile.auth_key() == Some(auth_key) => { + let uid = profile.uid(); match result { Ok(user) => match &mut profile.auth { - Some(auth) if auth.user != *user => { - user.clone_into(&mut auth.user); - Effects::msg(Msg::Event(Event::UserPulledFromAPI { uid: profile.uid() })) - .join(Effects::msg(Msg::Internal(Internal::ProfileChanged))) + Some(auth) => { + let profile_effects = if auth.user != *user { + user.clone_into(&mut auth.user); + + Effects::msg(Msg::Event(Event::UserPulledFromAPI { uid: uid.clone() })) + .join(Effects::msg(Msg::Internal(Internal::ProfileChanged))) + } else { + Effects::msg(Msg::Event(Event::UserPulledFromAPI { uid: uid.clone() })) + .unchanged() + }; + + let refresh_trakt_effects = match user.trakt.as_ref() { + Some(trakt_info) + if trakt_info.created_at + trakt_info.expires_in < E::now() => + { + // in case of success, trakt token won't be expired so checking for only error + 24h have passed is sufficient + + match &*refresh_trakt { + Some(RefreshTrakt { + last_requested, + response: loadable, + .. + }) if loadable.is_err() + && E::now() - *last_requested + > chrono::TimeDelta::hours(24) => + { + let (new_request, refresh_effect) = + refresh_trakt_token_api::(auth_key.clone()); + let api_request_effects = + Effects::one(refresh_effect).unchanged(); + + eq_update( + refresh_trakt, + Some(RefreshTrakt { + request: new_request, + last_requested: E::now(), + response: Loadable::Loading, + }), + ) + .join(api_request_effects) + } + None => { + let (request, refresh_effect) = + refresh_trakt_token_api::(auth_key.clone()); + let api_request_effects = + Effects::one(refresh_effect).unchanged(); + + eq_update( + refresh_trakt, + Some(RefreshTrakt { + request: request.to_owned(), + last_requested: E::now(), + response: Loadable::Loading, + }), + ) + .join(api_request_effects) + } + _ => Effects::none().unchanged(), + } + } + _ => Effects::none().unchanged(), + }; + + profile_effects.join(refresh_trakt_effects) } _ => Effects::msg(Msg::Event(Event::UserPulledFromAPI { uid: profile.uid() })) .unchanged(), @@ -390,6 +459,66 @@ pub fn update_profile( } } } + Msg::Internal(Internal::UserRefreshTraktTokenAPIResult(request, result)) => { + match profile.auth.as_ref() { + Some(auth) if auth.key == request.auth_key => { + let profile_user_effects = match result { + Ok(new_user) => { + let mut new_profile = profile.clone(); + new_profile.auth = new_profile.auth.map(|mut auth| { + auth.user = new_user.clone(); + auth + }); + + let token_refreshed_effects = + Effects::msg(Msg::Event(Event::TraktTokenRefreshed { + uid: profile.uid(), + })) + .unchanged(); + + let profile_effects = eq_update(profile, new_profile); + + profile_effects + .join(token_refreshed_effects) + .join(Effects::msg(Msg::Internal(Internal::ProfileChanged))) + } + Err(err) => { + let event = Event::TraktTokenRefreshed { uid: profile.uid() }; + // todo: implement Error and Display for the CtxError and all underlying errors + tracing::error!( + "Refreshing trakt token failed for {:?} : {err:?}", + profile.uid(), + ); + + Effects::msg(Msg::Event(Event::Error { + error: CtxError::Env(EnvError::Other( + "Failed to refresh trakt token, please re-authenticate.".into(), + )), + source: Box::new(event), + })) + } + }; + // use the same last requested or use the now() + // the request that resulted in this result should have set the last_requested field + let last_requested = refresh_trakt + .as_ref() + .map(|refresh_trakt| refresh_trakt.last_requested.to_owned()) + .unwrap_or_else(E::now); + + let refresh_trakt_effects = eq_update( + refresh_trakt, + Some(RefreshTrakt { + request: request.to_owned(), + last_requested, + response: result.to_owned().into(), + }), + ); + + profile_user_effects.join(refresh_trakt_effects) + } + _ => Effects::none().unchanged(), + } + } Msg::Internal(Internal::DeleteAccountAPIResult( APIRequest::DeleteAccount { auth_key, .. }, result, @@ -434,6 +563,29 @@ fn push_addons_to_api(addons: Vec, auth_key: &Auth .into() } +fn refresh_trakt_token_api(auth_key: AuthKey) -> (RefreshTraktToken, Effect) { + let request = RefreshTraktToken { auth_key }; + + let request2 = request.clone(); + let request_effect = EffectFuture::Concurrent( + async move { + let result = fetch_api::(&request2) + .await + .map_err(CtxError::from) + .and_then(|api_result| api_result.into_result().map_err(CtxError::from)); + + Msg::Internal(Internal::UserRefreshTraktTokenAPIResult( + request2.clone(), + result, + )) + } + .boxed_env(), + ) + .into(); + + (request, request_effect) +} + fn pull_user_from_api(auth_key: &AuthKey) -> Effect { let request = APIRequest::GetUser { auth_key: auth_key.to_owned(), diff --git a/src/runtime/msg/action_user_profiles.rs b/src/runtime/msg/action_user_profiles.rs new file mode 100644 index 000000000..cc79779c2 --- /dev/null +++ b/src/runtime/msg/action_user_profiles.rs @@ -0,0 +1,262 @@ +use std::ops::Range; + +use serde::Deserialize; +use url::Url; + +use crate::types::profile::Password; +use crate::types::streams::StreamItemState; +use crate::{ + models::{ + addon_details::Selected as AddonDetailsSelected, + calendar::Selected as CalendarSelected, + catalog_with_filters::Selected as CatalogWithFiltersSelected, + catalogs_with_extra::Selected as CatalogsWithExtraSelected, + installed_addons_with_filters::Selected as InstalledAddonsWithFiltersSelected, + library_by_type::Selected as LibraryByTypeSelected, + library_with_filters::Selected as LibraryWithFiltersSelected, + meta_details::Selected as MetaDetailsSelected, + player::{Selected as PlayerSelected, VideoParams}, + }, + types::{ + addon::Descriptor, + api::AuthRequest, + library::LibraryItemId, + profile::Settings as ProfileSettings, + resource::{MetaItemId, MetaItemPreview, Video}, + streaming_server::{ + Settings as StreamingServerSettings, + StatisticsRequest as StreamingServerStatisticsRequest, + }, + }, +}; + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionCtx { + Authenticate(AuthRequest), + Logout, + DeleteAccount(Password), + InstallAddon(Descriptor), + InstallTraktAddon, + LogoutTrakt, + UpgradeAddon(Descriptor), + UninstallAddon(Descriptor), + UpdateSettings(ProfileSettings), + AddToLibrary(MetaItemPreview), + RemoveFromLibrary(String), + RewindLibraryItem(String), + LibraryItemMarkAsWatched { + id: LibraryItemId, + is_watched: bool, + }, + /// If boolean is set to `true` it will disable notifications for the LibraryItem. + ToggleLibraryItemNotifications(LibraryItemId, bool), + /// Dismiss all Notification for a given [`MetaItemId`]. + DismissNotificationItem(MetaItemId), + ClearSearchHistory, + PushUserToAPI, + PullUserFromAPI, + PushAddonsToAPI, + PullAddonsFromAPI, + SyncLibraryWithAPI, + /// Pull notifications for all [`LibraryItem`]s that we should pull notifications for. + /// + /// **Warning:** The action will **always** trigger requests to the addons. + /// + /// See `LibraryItem::should_pull_notifications()` + /// + /// [`LibraryItem`]: crate::types::library::LibraryItem + PullNotifications, + /// Make request to api to get events modal and notification + GetEvents, + /// Dismiss an event by id, either a Modal or Notification + DismissEvent(String), + /// Add a server URL to the list of available streaming servers + AddServerUrl(Url), + /// Delete a server URL from the list of available streaming servers + DeleteServerUrl(Url), + GetUserSiblings(), + SetUserPin(), + AdoptUser(), + DisownUser(String), +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionCatalogWithFilters { + LoadNextPage, +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionCatalogsWithExtra { + LoadRange(Range), + LoadNextPage(usize), +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionLibraryByType { + LoadNextPage(usize), +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionLibraryWithFilters { + LoadNextPage, +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionMetaDetails { + /// Marks the [`LibraryItem`] as watched. + /// + /// Applicable when you have single-video (e.g. a movie) and multi-video (e.g. a movie series) item. + /// + /// [`LibraryItem`]: crate::types::library::LibraryItem + MarkAsWatched(bool), + /// Marks the given [`Video`] of the [`LibraryItem`] as watched. + /// + /// Applicable only when you have a multi-video (e.g. movie series) item. + /// + /// [`LibraryItem`]: crate::types::library::LibraryItem + MarkVideoAsWatched(Video, bool), + /// Mark all videos from given season as watched + MarkSeasonAsWatched(u32, bool), +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(untagged)] +pub enum CreateTorrentArgs { + File(Vec), + Magnet(Url), +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlayOnDeviceArgs { + pub device: String, + pub source: String, + pub time: Option, +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionStreamingServer { + Reload, + UpdateSettings(StreamingServerSettings), + CreateTorrent(CreateTorrentArgs), + GetStatistics(StreamingServerStatisticsRequest), + PlayOnDevice(PlayOnDeviceArgs), +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionLink { + ReadData, +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionPlayer { + #[serde(rename_all = "camelCase")] + VideoParamsChanged { + video_params: Option, + }, + StreamStateChanged { + state: StreamItemState, + }, + /// Seek performed by the user when using the seekbar or + /// the shortcuts for seeking. + /// + /// When transitioning from Seek to TimeChanged and vice-versa + /// we need to make sure to update the other accordingly + /// if we have any type of throttling of these events, + /// otherwise we will get wrong `LibraryItem.state.time_offset`! + Seek { + time: u64, + duration: u64, + device: String, + }, + /// A normal playback by the video player + /// + /// The time from one TimeChanged action to another can only grow (move forward) + /// and should never go backwards, except when a [`ActionPlayer::Seek`] happen + /// and moves the time backwards. + TimeChanged { + time: u64, + duration: u64, + device: String, + }, + 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, + /// Marks the given [`Video`] of the [`LibraryItem`] as watched. + /// + /// Applicable only when you have a multi-video (e.g. movie series) item. + /// + /// [`LibraryItem`]: crate::types::library::LibraryItem + MarkVideoAsWatched(Video, bool), + /// Mark all videos from given season as watched + MarkSeasonAsWatched(u32, bool), +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "model", content = "args")] +/// Action to load a specific Model. +pub enum ActionLoad { + AddonDetails(AddonDetailsSelected), + CatalogWithFilters(Option), + CatalogsWithExtra(CatalogsWithExtraSelected), + DataExport, + InstalledAddonsWithFilters(InstalledAddonsWithFiltersSelected), + LibraryWithFilters(LibraryWithFiltersSelected), + LibraryByType(LibraryByTypeSelected), + /// Loads the Calendar Model + Calendar(Option), + /// Loads the data required for Local search + LocalSearch, + MetaDetails(MetaDetailsSelected), + Player(Box), + Link, +} + +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum ActionSearch { + /// Request for Search queries + #[serde(rename_all = "camelCase")] + Search { + search_query: String, + max_results: usize, + }, +} + +/// Action messages +/// +/// Those messages are meant to be dispatched only by the users of the +/// `stremio-core` crate and handled by the `stremio-core` crate. +#[derive(Clone, Deserialize, Debug)] +#[serde(tag = "action", content = "args")] +pub enum Action { + Ctx(ActionCtx), + Link(ActionLink), + CatalogWithFilters(ActionCatalogWithFilters), + CatalogsWithExtra(ActionCatalogsWithExtra), + LibraryByType(ActionLibraryByType), + LibraryWithFilters(ActionLibraryWithFilters), + MetaDetails(ActionMetaDetails), + StreamingServer(ActionStreamingServer), + Player(ActionPlayer), + Load(ActionLoad), + Search(ActionSearch), + Unload, +} diff --git a/src/runtime/msg/event.rs b/src/runtime/msg/event.rs index 0f068fe6d..a60262486 100644 --- a/src/runtime/msg/event.rs +++ b/src/runtime/msg/event.rs @@ -97,6 +97,9 @@ pub enum Event { TraktAddonFetched { uid: UID, }, + TraktTokenRefreshed { + uid: UID, + }, TraktLoggedOut { uid: UID, }, diff --git a/src/runtime/msg/internal.rs b/src/runtime/msg/internal.rs index e9f5ada03..4d770281f 100644 --- a/src/runtime/msg/internal.rs +++ b/src/runtime/msg/internal.rs @@ -9,8 +9,8 @@ use crate::runtime::EnvError; use crate::types::addon::{Descriptor, Manifest, ResourceRequest, ResourceResponse}; use crate::types::api::{ APIRequest, AuthRequest, DataExportResponse, DatastoreRequest, GetModalResponse, - GetNotificationResponse, LinkCodeResponse, LinkDataResponse, SeekLogRequest, SkipGapsRequest, - SkipGapsResponse, SuccessResponse, + GetNotificationResponse, LinkCodeResponse, LinkDataResponse, RefreshTraktToken, SeekLogRequest, + SkipGapsRequest, SkipGapsResponse, SuccessResponse, }; use crate::types::library::{LibraryBucket, LibraryItem, LibraryItemId}; use crate::types::profile::{Auth, AuthKey, Profile, User}; @@ -49,6 +49,8 @@ pub enum Internal { AddonsAPIResult(APIRequest, Result, CtxError>), /// Result for pull user from API. UserAPIResult(APIRequest, Result), + /// Result from refreshing an expired Trakt token + UserRefreshTraktTokenAPIResult(RefreshTraktToken, Result), /// Result for deleting account from API. DeleteAccountAPIResult(APIRequest, Result), /// Result for library sync plan with API. diff --git a/src/types/api/request.rs b/src/types/api/request.rs index 0f42ac889..c1c67c426 100644 --- a/src/types/api/request.rs +++ b/src/types/api/request.rs @@ -343,6 +343,30 @@ pub enum DatastoreCommand { }, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RefreshTraktToken { + pub auth_key: AuthKey, +} + +impl FetchRequestParams for RefreshTraktToken { + fn endpoint(&self) -> Url { + API_URL.to_owned() + } + fn method(&self) -> Method { + Method::POST + } + fn path(&self) -> String { + "traktRefresh".to_string() + } + fn query(&self) -> Option { + None + } + fn body(self) -> Self { + self + } +} + #[cfg(test)] mod tests { use http::Method; diff --git a/stremio-core-web/src/model/serialize_ctx.rs b/stremio-core-web/src/model/serialize_ctx.rs index e758bbfb1..f19455563 100644 --- a/stremio-core-web/src/model/serialize_ctx.rs +++ b/stremio-core-web/src/model/serialize_ctx.rs @@ -15,9 +15,13 @@ mod model { use itertools::Itertools; use serde::Serialize; - use stremio_core::deep_links::SearchHistoryItemDeepLinks; - use stremio_core::types::{ - events::Events, notifications::NotificationItem, resource::MetaItemId, + use stremio_core::{ + deep_links::SearchHistoryItemDeepLinks, + models::{ + common::Loadable, + ctx::{CtxError, RefreshTrakt}, + }, + types::{events::Events, notifications::NotificationItem, resource::MetaItemId}, }; use url::Url; @@ -31,6 +35,8 @@ mod model { pub search_history: Vec>, pub events: &'a Events, pub streaming_server_urls: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub trakt_refreshed: Option<(DateTime, Loadable<(), CtxError>)>, } #[derive(Serialize)] @@ -125,6 +131,15 @@ mod model { }) .sorted_by(|a, b| Ord::cmp(&a.mtime, &b.mtime)) .collect(), + trakt_refreshed: ctx.refresh_trakt.as_ref().map( + |RefreshTrakt { + response: loadable, + last_requested, + .. + }| { + (last_requested.to_owned(), loadable.clone().map_ready(drop)) + }, + ), } } }