Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 79 additions & 17 deletions src/trakt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,34 @@ 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<TraktWatchingResponse> {
let endpoint = format!("https://api.trakt.tv/users/{}/watching", self.username);

Expand All @@ -87,6 +115,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()
Expand All @@ -99,7 +128,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()
Expand All @@ -120,20 +156,32 @@ 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::<serde_json::Value>() {
match response.into_json::<serde_json::Value>() {
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;
}

Expand All @@ -146,12 +194,16 @@ impl Trakt {
.as_str()
.unwrap()
);

// Cache the image URL
self.image_cache.insert(tmdb_id, image_url.clone());
Some(image_url)
}
Err(_) => {
Err(e) => {
log(&format!(
"{} image not correctly found",
media_type.as_str()
"Failed to parse {} image response: {}",
media_type.as_str(),
e
));
None
}
Expand All @@ -175,7 +227,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::<TraktRatingsResponse>() {
Expand All @@ -184,7 +243,10 @@ 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
}
}
}
}
Expand Down
125 changes: 105 additions & 20 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,42 @@ 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();
Expand All @@ -69,10 +87,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")
Expand All @@ -85,52 +106,116 @@ 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<TraktAccessToken> = 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",
"grant_type": "refresh_token",
}))
{
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<TraktAccessToken> = 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();
}
}
}
Expand All @@ -148,13 +233,13 @@ fn find_config_file() -> Option<PathBuf> {
return Some(config_file);
}
}
eprintln!(
log(&format!(
"Could not find credentials.ini in {:?}",
locations
.iter()
.map(|loc| loc.to_str().to_owned().unwrap())
.collect::<Vec<_>>()
);
));
None
}

Expand Down
Loading