Skip to content

Commit f39caae

Browse files
fix: use Trakt runtime to derive watch start time (#225)
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
1 parent 6f07f77 commit f39caae

6 files changed

Lines changed: 184 additions & 6 deletions

File tree

src/trakt.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,15 @@ pub struct TraktMovie {
7070
pub title: String,
7171
pub year: u16,
7272
pub ids: TraktIds,
73+
pub runtime: Option<u16>,
7374
}
7475

7576
#[derive(Deserialize, Debug, Clone, PartialEq)]
7677
pub struct TraktShow {
7778
pub title: String,
7879
pub year: u16,
7980
pub ids: TraktIds,
81+
pub runtime: Option<u16>,
8082
}
8183

8284
#[derive(Deserialize, Debug, Clone, PartialEq)]
@@ -85,6 +87,7 @@ pub struct TraktEpisode {
8587
pub number: u8,
8688
pub title: String,
8789
pub ids: TraktIds,
90+
pub runtime: Option<u16>,
8891
}
8992

9093
#[derive(Deserialize, Debug, Clone, PartialEq)]
@@ -221,10 +224,13 @@ impl Trakt {
221224
.is_some_and(|t| !t.is_empty());
222225

223226
let endpoint = if has_oauth {
224-
format!("{}/users/me/watching", self.trakt_base_url)
227+
format!("{}/users/me/watching?extended=full", self.trakt_base_url)
225228
} else {
226229
let encoded = utf8_percent_encode(&self.username, NON_ALPHANUMERIC).to_string();
227-
format!("{}/users/{}/watching", self.trakt_base_url, encoded)
230+
format!(
231+
"{}/users/{}/watching?extended=full",
232+
self.trakt_base_url, encoded
233+
)
228234
};
229235

230236
let authorization = self.oauth_access_token.as_ref().and_then(|token| {

src/utils.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ pub struct WatchStats {
180180
pub watch_percentage: String,
181181
pub start_date: DateTime<FixedOffset>,
182182
pub end_date: DateTime<FixedOffset>,
183+
/// Runtime in minutes from Trakt (None if unavailable, using session times as fallback).
184+
pub runtime_minutes: Option<u16>,
183185
}
184186

185187
/// Result of polling for a device token.
@@ -788,17 +790,53 @@ pub fn set_restrictive_permissions(_path: &std::path::Path) {}
788790
pub fn get_watch_stats(trakt_response: &TraktWatchingResponse) -> WatchStats {
789791
let start_date = DateTime::parse_from_rfc3339(&trakt_response.started_at).unwrap();
790792
let end_date = DateTime::parse_from_rfc3339(&trakt_response.expires_at).unwrap();
791-
let percentage = Utc::now().signed_duration_since(start_date).num_seconds() as f32
792-
/ end_date.signed_duration_since(start_date).num_seconds() as f32;
793+
794+
let runtime_minutes = extract_runtime_minutes(trakt_response);
795+
let (start_date, end_date) = match runtime_minutes {
796+
Some(minutes) => {
797+
let duration = chrono::Duration::minutes(i64::from(minutes));
798+
(end_date - duration, end_date)
799+
}
800+
None => {
801+
tracing::trace!("No runtime available, using Trakt session times as fallback");
802+
(start_date, end_date)
803+
}
804+
};
805+
806+
// Prevent division by zero if dates are somehow equal
807+
let total_seconds = end_date
808+
.signed_duration_since(start_date)
809+
.num_seconds()
810+
.max(1);
811+
let percentage =
812+
Utc::now().signed_duration_since(start_date).num_seconds() as f32 / total_seconds as f32;
793813
let watch_percentage = format!("{:.2}%", percentage * 100.0);
794814

795815
WatchStats {
796816
watch_percentage,
797817
start_date,
798818
end_date,
819+
runtime_minutes,
799820
}
800821
}
801822

823+
fn extract_runtime_minutes(trakt_response: &TraktWatchingResponse) -> Option<u16> {
824+
let minutes = match trakt_response.r#type.as_str() {
825+
"movie" => trakt_response
826+
.movie
827+
.as_ref()
828+
.and_then(|movie| movie.runtime),
829+
"episode" => trakt_response
830+
.episode
831+
.as_ref()
832+
.and_then(|episode| episode.runtime)
833+
.or_else(|| trakt_response.show.as_ref().and_then(|show| show.runtime)),
834+
_ => None,
835+
};
836+
837+
minutes.filter(|&value| value > 0)
838+
}
839+
802840
pub enum MediaType {
803841
Show,
804842
Movie,

tests/common/fixtures.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ pub const TRAKT_MOVIE_WATCHING: &str = r#"{
1818
"imdb": "tt1375666",
1919
"tmdb": 27205,
2020
"tvrage": null
21-
}
21+
},
22+
"runtime": 150
2223
}
2324
}"#;
2425

@@ -50,7 +51,42 @@ pub const TRAKT_EPISODE_WATCHING: &str = r#"{
5051
"imdb": "tt2301451",
5152
"tmdb": 62161,
5253
"tvrage": null
53-
}
54+
},
55+
"runtime": 60
56+
}
57+
}"#;
58+
59+
/// Trakt API: Episode watching response with stale started_at and runtime
60+
pub const TRAKT_EPISODE_WATCHING_STALE_START: &str = r#"{
61+
"expires_at": "2024-01-15T11:00:00.000Z",
62+
"started_at": "2024-01-15T08:00:00.000Z",
63+
"action": "checkin",
64+
"type": "episode",
65+
"show": {
66+
"title": "Stargate SG-1",
67+
"year": 1997,
68+
"ids": {
69+
"trakt": 4605,
70+
"slug": "stargate-sg-1",
71+
"tvdb": 72449,
72+
"imdb": "tt0118480",
73+
"tmdb": 4629,
74+
"tvrage": null
75+
},
76+
"runtime": 44
77+
},
78+
"episode": {
79+
"season": 4,
80+
"number": 7,
81+
"title": "Watergate",
82+
"ids": {
83+
"trakt": 344183,
84+
"tvdb": 85823,
85+
"imdb": "tt0709217",
86+
"tmdb": 335902,
87+
"tvrage": null
88+
},
89+
"runtime": 44
5490
}
5591
}"#;
5692

tests/discord_tests.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ fn create_movie_response() -> TraktWatchingResponse {
7171
tmdb: Some(27205),
7272
tvrage: None,
7373
},
74+
runtime: None,
7475
}),
7576
show: None,
7677
episode: None,
@@ -142,6 +143,7 @@ fn create_episode_response() -> TraktWatchingResponse {
142143
tmdb: Some(1396),
143144
tvrage: Some(18164),
144145
},
146+
runtime: None,
145147
}),
146148
episode: Some(TraktEpisode {
147149
season: 5,
@@ -155,6 +157,7 @@ fn create_episode_response() -> TraktWatchingResponse {
155157
tmdb: Some(62161),
156158
tvrage: None,
157159
},
160+
runtime: None,
158161
}),
159162
}
160163
}
@@ -243,6 +246,7 @@ fn test_build_payload_movie_missing_ids() {
243246
tmdb: None,
244247
tvrage: None,
245248
},
249+
runtime: None,
246250
}),
247251
show: None,
248252
episode: None,

tests/trakt_tests.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@ fn test_get_watching_returns_movie() {
245245

246246
let mock = server
247247
.mock("GET", "/users/testuser/watching")
248+
.match_query(mockito::Matcher::UrlEncoded(
249+
"extended".into(),
250+
"full".into(),
251+
))
248252
.match_header("trakt-api-version", "2")
249253
.match_header("trakt-api-key", "test_client")
250254
.with_status(200)
@@ -277,6 +281,10 @@ fn test_get_watching_returns_episode() {
277281

278282
let mock = server
279283
.mock("GET", "/users/testuser/watching")
284+
.match_query(mockito::Matcher::UrlEncoded(
285+
"extended".into(),
286+
"full".into(),
287+
))
280288
.match_header("trakt-api-version", "2")
281289
.with_status(200)
282290
.with_header("content-type", "application/json")
@@ -309,6 +317,10 @@ fn test_get_watching_returns_none_when_not_watching() {
309317

310318
let mock = server
311319
.mock("GET", "/users/testuser/watching")
320+
.match_query(mockito::Matcher::UrlEncoded(
321+
"extended".into(),
322+
"full".into(),
323+
))
312324
.with_status(204) // No Content
313325
.create();
314326

@@ -333,6 +345,10 @@ fn test_get_watching_handles_401() {
333345

334346
let mock = server
335347
.mock("GET", "/users/testuser/watching")
348+
.match_query(mockito::Matcher::UrlEncoded(
349+
"extended".into(),
350+
"full".into(),
351+
))
336352
.with_status(401)
337353
.create();
338354

@@ -357,6 +373,10 @@ fn test_get_watching_handles_403() {
357373

358374
let mock = server
359375
.mock("GET", "/users/testuser/watching")
376+
.match_query(mockito::Matcher::UrlEncoded(
377+
"extended".into(),
378+
"full".into(),
379+
))
360380
.with_status(403)
361381
.create();
362382

@@ -382,6 +402,10 @@ fn test_get_watching_with_oauth_uses_me_endpoint() {
382402
// When OAuth token is present, should use /users/me/watching instead of /users/{username}/watching
383403
let mock = server
384404
.mock("GET", "/users/me/watching")
405+
.match_query(mockito::Matcher::UrlEncoded(
406+
"extended".into(),
407+
"full".into(),
408+
))
385409
.match_header("Authorization", "Bearer my_oauth_token")
386410
.with_status(200)
387411
.with_body(common::fixtures::TRAKT_MOVIE_WATCHING)
@@ -409,6 +433,10 @@ fn test_get_watching_encodes_special_chars_in_username() {
409433
// Username with spaces should be URL-encoded when no OAuth token is present
410434
let mock = server
411435
.mock("GET", "/users/john%20doe/watching")
436+
.match_query(mockito::Matcher::UrlEncoded(
437+
"extended".into(),
438+
"full".into(),
439+
))
412440
.with_status(200)
413441
.with_body(common::fixtures::TRAKT_MOVIE_WATCHING)
414442
.create();
@@ -435,6 +463,10 @@ fn test_get_watching_empty_oauth_uses_username_endpoint() {
435463
// Empty OAuth token should fall back to /users/{username}/watching
436464
let mock = server
437465
.mock("GET", "/users/testuser/watching")
466+
.match_query(mockito::Matcher::UrlEncoded(
467+
"extended".into(),
468+
"full".into(),
469+
))
438470
.with_status(200)
439471
.with_body(common::fixtures::TRAKT_MOVIE_WATCHING)
440472
.create();
@@ -1076,18 +1108,30 @@ fn test_get_watching_retries_on_503() {
10761108
// First two calls return 503, third call succeeds
10771109
let mock_503_first = server
10781110
.mock("GET", "/users/testuser/watching")
1111+
.match_query(mockito::Matcher::UrlEncoded(
1112+
"extended".into(),
1113+
"full".into(),
1114+
))
10791115
.with_status(503)
10801116
.expect(1)
10811117
.create();
10821118

10831119
let mock_503_second = server
10841120
.mock("GET", "/users/testuser/watching")
1121+
.match_query(mockito::Matcher::UrlEncoded(
1122+
"extended".into(),
1123+
"full".into(),
1124+
))
10851125
.with_status(503)
10861126
.expect(1)
10871127
.create();
10881128

10891129
let mock_success = server
10901130
.mock("GET", "/users/testuser/watching")
1131+
.match_query(mockito::Matcher::UrlEncoded(
1132+
"extended".into(),
1133+
"full".into(),
1134+
))
10911135
.with_status(200)
10921136
.with_header("content-type", "application/json")
10931137
.with_body(common::fixtures::TRAKT_MOVIE_WATCHING)
@@ -1127,18 +1171,30 @@ fn test_get_watching_retries_on_429() {
11271171
// First two calls return 429 (rate limited), third call succeeds
11281172
let mock_429_first = server
11291173
.mock("GET", "/users/testuser/watching")
1174+
.match_query(mockito::Matcher::UrlEncoded(
1175+
"extended".into(),
1176+
"full".into(),
1177+
))
11301178
.with_status(429)
11311179
.expect(1)
11321180
.create();
11331181

11341182
let mock_429_second = server
11351183
.mock("GET", "/users/testuser/watching")
1184+
.match_query(mockito::Matcher::UrlEncoded(
1185+
"extended".into(),
1186+
"full".into(),
1187+
))
11361188
.with_status(429)
11371189
.expect(1)
11381190
.create();
11391191

11401192
let mock_success = server
11411193
.mock("GET", "/users/testuser/watching")
1194+
.match_query(mockito::Matcher::UrlEncoded(
1195+
"extended".into(),
1196+
"full".into(),
1197+
))
11421198
.with_status(200)
11431199
.with_header("content-type", "application/json")
11441200
.with_body(common::fixtures::TRAKT_EPISODE_WATCHING)
@@ -1176,6 +1232,10 @@ fn test_get_watching_gives_up_after_max_retries() {
11761232
// Server always returns 503 - should hit max_retries + 1 times (initial + retries)
11771233
let mock_503 = server
11781234
.mock("GET", "/users/testuser/watching")
1235+
.match_query(mockito::Matcher::UrlEncoded(
1236+
"extended".into(),
1237+
"full".into(),
1238+
))
11791239
.with_status(503)
11801240
.expect(4) // 1 initial + 3 retries = 4 total attempts
11811241
.create();

tests/utils_tests.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
mod common;
44

5+
use chrono::{DateTime, Duration};
56
use discrakt::trakt::TraktWatchingResponse;
67
#[cfg(target_os = "macos")]
78
use discrakt::utils::is_light_mode;
@@ -80,6 +81,39 @@ fn test_get_watch_stats_dates_parsed() {
8081
assert!(stats.end_date.timestamp() > stats.start_date.timestamp());
8182
}
8283

84+
#[test]
85+
fn test_get_watch_stats_uses_runtime_over_stale_start() {
86+
let response: TraktWatchingResponse =
87+
serde_json::from_str(common::fixtures::TRAKT_EPISODE_WATCHING_STALE_START).unwrap();
88+
89+
let stats = get_watch_stats(&response);
90+
91+
let end_date =
92+
DateTime::parse_from_rfc3339("2024-01-15T11:00:00.000Z").expect("valid end date");
93+
let expected_start = end_date - Duration::minutes(44);
94+
95+
assert_eq!(stats.end_date.timestamp(), end_date.timestamp());
96+
assert_eq!(stats.start_date.timestamp(), expected_start.timestamp());
97+
assert_eq!(stats.runtime_minutes, Some(44));
98+
}
99+
100+
#[test]
101+
fn test_get_watch_stats_movie_with_runtime() {
102+
// Movie fixture has runtime: 150
103+
let response: TraktWatchingResponse =
104+
serde_json::from_str(common::fixtures::TRAKT_MOVIE_WATCHING).unwrap();
105+
106+
let stats = get_watch_stats(&response);
107+
108+
let end_date =
109+
DateTime::parse_from_rfc3339("2024-01-15T12:30:00.000Z").expect("valid end date");
110+
let expected_start = end_date - Duration::minutes(150);
111+
112+
assert_eq!(stats.runtime_minutes, Some(150));
113+
assert_eq!(stats.end_date.timestamp(), end_date.timestamp());
114+
assert_eq!(stats.start_date.timestamp(), expected_start.timestamp());
115+
}
116+
83117
// ============================================================================
84118
// Constants Tests
85119
// ============================================================================

0 commit comments

Comments
 (0)