From 7d2386f1a62511877d3c30c4bce80bf91173c22b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Skyler=20M=C3=A4ntysaari?= Date: Mon, 4 Aug 2025 01:00:03 +0300 Subject: [PATCH 1/2] fix(oauth): improve error handling and token management for OAuth flow --- src/trakt.rs | 77 +++++++++++++++++++++++--------- src/utils.rs | 122 ++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 158 insertions(+), 41 deletions(-) diff --git a/src/trakt.rs b/src/trakt.rs index 3bbd942..3476381 100644 --- a/src/trakt.rs +++ b/src/trakt.rs @@ -78,6 +78,23 @@ impl Trakt { } } + fn handle_auth_error(&self, status_code: u16, endpoint: &str) { + match status_code { + 401 => { + if self.oauth_access_token.is_some() { + log(&format!("OAuth token expired or invalid for endpoint: {}", endpoint)); + log("Please refresh your OAuth token to continue using authenticated endpoints"); + } else { + log(&format!("Authentication required for endpoint: {}", endpoint)); + } + } + 403 => { + log(&format!("Access forbidden for endpoint: {} - check token permissions", endpoint)); + } + _ => {} + } + } + pub fn get_watching(&self) -> Option { let endpoint = format!("https://api.trakt.tv/users/{}/watching", self.username); @@ -87,6 +104,7 @@ impl Trakt { .set("Content-Type", "application/json") .set("trakt-api-version", "2") .set("trakt-api-key", &self.client_id); + // add Authorization header if there is a (valid) OAuth access token let request = if self.oauth_access_token.is_some() && !self.oauth_access_token.as_ref().unwrap().is_empty() @@ -99,7 +117,14 @@ impl Trakt { let response = match request.call() { Ok(response) => response, - Err(_) => return None, + Err(ureq::Error::Status(code, _)) => { + self.handle_auth_error(code, &endpoint); + return None; + } + Err(e) => { + log(&format!("Network error calling {}: {}", endpoint, e)); + return None; + } }; response.into_json().unwrap_or_default() @@ -120,20 +145,22 @@ impl Trakt { MediaType::Show => format!("https://api.themoviedb.org/3/tv/{tmdb_id}/season/{season_id}/images?api_key={tmdb_token}") }; - let response = self.agent.get(&endpoint).call(); - - if response.is_err() { - log(&format!( - "{} image not correctly found", - media_type.as_str() - )); - return None; - } + let response = match self.agent.get(&endpoint).call() { + Ok(response) => response, + Err(ureq::Error::Status(401, _)) => { + log(&format!("TMDB API key expired or invalid for endpoint: {}", endpoint)); + return None; + } + Err(e) => { + log(&format!("Error fetching {} image: {}", media_type.as_str(), e)); + return None; + } + }; - match response.unwrap().into_json::() { + match response.into_json::() { Ok(body) => { if body["posters"].as_array().unwrap_or(&vec![]).is_empty() { - log("Show image not correctly found"); + log(&format!("{} image not found in TMDB response", media_type.as_str())); return None; } @@ -146,13 +173,13 @@ impl Trakt { .as_str() .unwrap() ); + + // Cache the image URL + self.image_cache.insert(tmdb_id, image_url.clone()); Some(image_url) } - Err(_) => { - log(&format!( - "{} image not correctly found", - media_type.as_str() - )); + Err(e) => { + log(&format!("Failed to parse {} image response: {}", media_type.as_str(), e)); None } } @@ -175,7 +202,14 @@ impl Trakt { .call() { Ok(response) => response, - Err(_) => return 0.0, + Err(ureq::Error::Status(code, _)) => { + self.handle_auth_error(code, &endpoint); + return 0.0; + } + Err(e) => { + log(&format!("Network error fetching movie rating: {}", e)); + return 0.0; + } }; match response.into_json::() { @@ -184,9 +218,12 @@ impl Trakt { .insert(movie_slug.to_string(), body.rating); body.rating } - Err(_) => 0.0, + Err(e) => { + log(&format!("Failed to parse movie rating response: {}", e)); + 0.0 + } } } } } -} +} \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs index 0daf3e3..f37c7a8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -36,24 +36,39 @@ pub struct WatchStats { impl Env { pub fn check_oauth(&mut self) { - if self.trakt_oauth_enabled { - if self.trakt_access_token.is_none() - || self.trakt_access_token.as_ref().unwrap().is_empty() - { + if !self.trakt_oauth_enabled { + return; + } + + // Check if we have no access token + if self.trakt_access_token.is_none() || self.trakt_access_token.as_ref().unwrap().is_empty() { + log("No OAuth access token found, starting authorization flow"); + self.authorize_app(); + return; + } + + // Check if the refresh token is expired (this is what you were originally checking) + if let Some(refresh_expires_at) = self.trakt_refresh_token_expires_at { + let now = Utc::now().timestamp() as u64; + if now >= refresh_expires_at { + log("OAuth refresh token has expired, need to reauthorize"); self.authorize_app(); - } else if let Some(expires_at) = self.trakt_refresh_token_expires_at { - if Utc::now().timestamp() as u64 > expires_at { - self.exchange_refresh_token_for_access_token(); - } + } else { + // Try to refresh the access token proactively + log("Refresh token is still valid, refreshing access token"); + self.exchange_refresh_token_for_access_token(); } + } else { + log("No refresh token expiry time found, unable to determine if refresh token is valid"); } } fn authorize_app(&mut self) { + log("Opening browser for OAuth authorization"); if webbrowser::open( &format!("https://trakt.tv/oauth/authorize?response_type=code&client_id={}&redirect_uri=urn:ietf:wg:oauth:2.0:oob", self.trakt_client_id) ).is_err() { - eprintln!("Failed to open webbrowser to authorize discrakt"); + log("Failed to open webbrowser to authorize discrakt"); return; }; self.exchange_code_for_access_token(); @@ -69,10 +84,13 @@ impl Env { .expect("Failed to read line"); let code = code.trim(); + log("Exchanging authorization code for access token"); + let agent = AgentBuilder::new() .timeout_read(Duration::from_secs(5)) .timeout_write(Duration::from_secs(5)) .build(); + let response = match agent .post("https://api.trakt.tv/oauth/token") .set("Content-Type", "application/json") @@ -85,32 +103,64 @@ impl Env { })) { Ok(response) => response, - Err(_) => return, + Err(ureq::Error::Status(code, response)) => { + log(&format!("Failed to exchange authorization code: HTTP {}", code)); + if let Ok(error_body) = response.into_string() { + log(&format!("Error details: {}", error_body)); + } + return; + } + Err(e) => { + log(&format!("Network error during token exchange: {}", e)); + return; + } }; let json_response: Option = response.into_json().unwrap_or_default(); if let Some(json_response) = json_response { + log("Successfully obtained OAuth tokens"); self.trakt_access_token = Some(json_response.access_token.clone()); self.trakt_refresh_token = Some(json_response.refresh_token.clone()); - self.trakt_refresh_token_expires_at = - Some(json_response.created_at + 60 * 60 * 24 * 30 * 3); // secs * mins * hours * days * months => 3 months + + // Calculate refresh token expiry (3 months from now) + let now = Utc::now().timestamp() as u64; + self.trakt_refresh_token_expires_at = Some(now + 60 * 60 * 24 * 30 * 3); // 3 months + set_oauth_tokens(&json_response); + + log(&format!("Tokens obtained successfully, refresh token expires at: {}", + DateTime::from_timestamp(self.trakt_refresh_token_expires_at.unwrap() as i64, 0) + .unwrap() + .to_rfc3339_opts(SecondsFormat::Secs, true) + )); } else { - eprintln!("Failed to exchange code for access token"); + log("Failed to parse token response from Trakt API"); } } fn exchange_refresh_token_for_access_token(&mut self) { + let refresh_token = match &self.trakt_refresh_token { + Some(token) if !token.is_empty() => token.clone(), + _ => { + log("No refresh token available, need to reauthorize"); + self.authorize_app(); + return; + } + }; + + log("Attempting to refresh OAuth access token"); + let agent = AgentBuilder::new() .timeout_read(Duration::from_secs(5)) .timeout_write(Duration::from_secs(5)) .build(); + let response = match agent .post("https://api.trakt.tv/oauth/token") .set("Content-Type", "application/json") .send_json(ureq::json!({ - "code": "Get the code from the webbrowser", + "refresh_token": refresh_token, "client_id": self.trakt_client_id, "client_secret": self.trakt_client_secret.as_ref().expect("client_secret not found"), "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", @@ -118,19 +168,49 @@ impl Env { })) { Ok(response) => response, - Err(_) => return, + Err(ureq::Error::Status(400, response)) => { + log("Refresh token is invalid or expired, need to reauthorize"); + if let Ok(error_body) = response.into_string() { + log(&format!("Error details: {}", error_body)); + } + self.authorize_app(); + return; + } + Err(ureq::Error::Status(code, response)) => { + log(&format!("Failed to refresh token: HTTP {}", code)); + if let Ok(error_body) = response.into_string() { + log(&format!("Error details: {}", error_body)); + } + return; + } + Err(e) => { + log(&format!("Network error during token refresh: {}", e)); + return; + } }; let json_response: Option = response.into_json().unwrap_or_default(); if let Some(json_response) = json_response { + log("Successfully refreshed OAuth access token"); self.trakt_access_token = Some(json_response.access_token.clone()); self.trakt_refresh_token = Some(json_response.refresh_token.clone()); - self.trakt_refresh_token_expires_at = - Some(json_response.created_at + 60 * 60 * 24 * 30 * 3); // secs * mins * hours * days * months => 3 months + + // Calculate refresh token expiry (3 months from now) + let now = Utc::now().timestamp() as u64; + self.trakt_refresh_token_expires_at = Some(now + 60 * 60 * 24 * 30 * 3); // 3 months + set_oauth_tokens(&json_response); + + log(&format!("Token refreshed successfully, new refresh token expires at: {}", + DateTime::from_timestamp(self.trakt_refresh_token_expires_at.unwrap() as i64, 0) + .unwrap() + .to_rfc3339_opts(SecondsFormat::Secs, true) + )); } else { - eprintln!("Failed to exchange refresh token for access token"); + log("Failed to parse refresh token response from Trakt API"); + log("Will attempt full reauthorization"); + self.authorize_app(); } } } @@ -148,13 +228,13 @@ fn find_config_file() -> Option { return Some(config_file); } } - eprintln!( + log(&format!( "Could not find credentials.ini in {:?}", locations .iter() .map(|loc| loc.to_str().to_owned().unwrap()) .collect::>() - ); + )); None } @@ -247,4 +327,4 @@ impl MediaType { MediaType::Movie => "movie", } } -} +} \ No newline at end of file From 04f064eba467c27ee4d08fa96306a1b069f07e68 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Tue, 19 Aug 2025 04:27:14 +0100 Subject: [PATCH 2/2] fix: apply formatting --- src/trakt.rs | 43 ++++++++++++++++++++++++++++++++++--------- src/utils.rs | 27 ++++++++++++++++----------- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/trakt.rs b/src/trakt.rs index 3476381..16d250e 100644 --- a/src/trakt.rs +++ b/src/trakt.rs @@ -82,14 +82,25 @@ impl Trakt { match status_code { 401 => { if self.oauth_access_token.is_some() { - log(&format!("OAuth token expired or invalid for endpoint: {}", endpoint)); - log("Please refresh your OAuth token to continue using authenticated endpoints"); + log(&format!( + "OAuth token expired or invalid for endpoint: {}", + endpoint + )); + log( + "Please refresh your OAuth token to continue using authenticated endpoints", + ); } else { - log(&format!("Authentication required for endpoint: {}", endpoint)); + log(&format!( + "Authentication required for endpoint: {}", + endpoint + )); } } 403 => { - log(&format!("Access forbidden for endpoint: {} - check token permissions", endpoint)); + log(&format!( + "Access forbidden for endpoint: {} - check token permissions", + endpoint + )); } _ => {} } @@ -148,11 +159,18 @@ impl Trakt { let response = match self.agent.get(&endpoint).call() { Ok(response) => response, Err(ureq::Error::Status(401, _)) => { - log(&format!("TMDB API key expired or invalid for endpoint: {}", endpoint)); + log(&format!( + "TMDB API key expired or invalid for endpoint: {}", + endpoint + )); return None; } Err(e) => { - log(&format!("Error fetching {} image: {}", media_type.as_str(), e)); + log(&format!( + "Error fetching {} image: {}", + media_type.as_str(), + e + )); return None; } }; @@ -160,7 +178,10 @@ impl Trakt { match response.into_json::() { Ok(body) => { if body["posters"].as_array().unwrap_or(&vec![]).is_empty() { - log(&format!("{} image not found in TMDB response", media_type.as_str())); + log(&format!( + "{} image not found in TMDB response", + media_type.as_str() + )); return None; } @@ -179,7 +200,11 @@ impl Trakt { Some(image_url) } Err(e) => { - log(&format!("Failed to parse {} image response: {}", media_type.as_str(), e)); + log(&format!( + "Failed to parse {} image response: {}", + media_type.as_str(), + e + )); None } } @@ -226,4 +251,4 @@ impl Trakt { } } } -} \ No newline at end of file +} diff --git a/src/utils.rs b/src/utils.rs index f37c7a8..6c77f21 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -41,7 +41,8 @@ impl Env { } // Check if we have no access token - if self.trakt_access_token.is_none() || self.trakt_access_token.as_ref().unwrap().is_empty() { + if self.trakt_access_token.is_none() || self.trakt_access_token.as_ref().unwrap().is_empty() + { log("No OAuth access token found, starting authorization flow"); self.authorize_app(); return; @@ -59,7 +60,9 @@ impl Env { self.exchange_refresh_token_for_access_token(); } } else { - log("No refresh token expiry time found, unable to determine if refresh token is valid"); + log( + "No refresh token expiry time found, unable to determine if refresh token is valid", + ); } } @@ -129,10 +132,11 @@ impl Env { set_oauth_tokens(&json_response); - log(&format!("Tokens obtained successfully, refresh token expires at: {}", - DateTime::from_timestamp(self.trakt_refresh_token_expires_at.unwrap() as i64, 0) - .unwrap() - .to_rfc3339_opts(SecondsFormat::Secs, true) + log(&format!( + "Tokens obtained successfully, refresh token expires at: {}", + DateTime::from_timestamp(self.trakt_refresh_token_expires_at.unwrap() as i64, 0) + .unwrap() + .to_rfc3339_opts(SecondsFormat::Secs, true) )); } else { log("Failed to parse token response from Trakt API"); @@ -202,10 +206,11 @@ impl Env { set_oauth_tokens(&json_response); - log(&format!("Token refreshed successfully, new refresh token expires at: {}", - DateTime::from_timestamp(self.trakt_refresh_token_expires_at.unwrap() as i64, 0) - .unwrap() - .to_rfc3339_opts(SecondsFormat::Secs, true) + log(&format!( + "Token refreshed successfully, new refresh token expires at: {}", + DateTime::from_timestamp(self.trakt_refresh_token_expires_at.unwrap() as i64, 0) + .unwrap() + .to_rfc3339_opts(SecondsFormat::Secs, true) )); } else { log("Failed to parse refresh token response from Trakt API"); @@ -327,4 +332,4 @@ impl MediaType { MediaType::Movie => "movie", } } -} \ No newline at end of file +}