This commit is contained in:
Jonas Rabenstein 2026-08-12 13:24:01 +02:00
commit a9d22e8312
18 changed files with 2308 additions and 0 deletions

292
src/intervals.rs Normal file
View file

@ -0,0 +1,292 @@
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde_json::{Value, json};
use crate::config::Config;
#[derive(Clone)]
pub struct IntervalsClient {
client: Client,
config: Config,
}
impl IntervalsClient {
pub fn new(config: Config) -> Self {
Self {
client: Client::new(),
config,
}
}
pub fn oauth_authorize_url(&self, state: &str) -> String {
format!(
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}",
self.config.intervals_base_url,
urlencoding::encode(&self.config.intervals_client_id),
urlencoding::encode(&self.config.intervals_redirect_uri),
urlencoding::encode("ACTIVITY:READ"),
urlencoding::encode(state),
)
}
pub async fn exchange_code(&self, code: &str) -> Result<Value> {
let url = format!(
"{}/api/oauth/token",
self.config.intervals_base_url
);
Ok(self
.client
.post(url)
.form(&[
("client_id", self.config.intervals_client_id.as_str()),
(
"client_secret",
self.config.intervals_client_secret.as_str(),
),
("code", code),
])
.send()
.await?
.error_for_status()?
.json()
.await?)
}
pub async fn activity(
&self,
access_token: &str,
activity_id: &str,
) -> Result<Value> {
let url = format!(
"{}/api/v1/activity/{}",
self.config.intervals_base_url,
activity_id
);
Ok(self
.client
.get(url)
.bearer_auth(access_token)
.query(&[("intervals", "true")])
.send()
.await?
.error_for_status()
.context("Intervals.icu activity request failed")?
.json()
.await?)
}
pub async fn streams(
&self,
access_token: &str,
activity_id: &str,
) -> Result<Value> {
let url = format!(
"{}/api/v1/activity/{}/streams.json",
self.config.intervals_base_url,
activity_id
);
Ok(self
.client
.get(url)
.bearer_auth(access_token)
.query(&[("types", "time,latlng")])
.send()
.await?
.error_for_status()
.context("Intervals.icu streams request failed")?
.json()
.await?)
}
pub fn activity_url(&self, activity_id: &str) -> String {
format!(
"{}/activities/{}",
self.config.intervals_base_url,
activity_id
)
}
pub fn interval_url(
&self,
activity_id: &str,
interval_id: i64,
) -> String {
format!(
"{}/activities/{}?interval={}",
self.config.intervals_base_url,
activity_id,
interval_id
)
}
pub fn stream_values(
streams: &Value,
wanted: &str,
) -> Option<Vec<Value>> {
if let Some(array) = streams.as_array() {
for entry in array {
if entry.get("type")
.and_then(Value::as_str)
== Some(wanted)
{
return entry
.get("data")
.and_then(Value::as_array)
.cloned();
}
}
}
if let Some(object) = streams.as_object() {
if let Some(value) = object.get(wanted) {
if let Some(data) = value.get("data").and_then(Value::as_array) {
return Some(data.clone());
}
if let Some(data) = value.as_array() {
return Some(data.clone());
}
}
}
None
}
pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> {
if let Some(value) = activity
.get("start_date")
.and_then(Value::as_str)
{
return Ok(value.parse()?);
}
if let Some(value) = activity
.get("start_date_local")
.and_then(Value::as_str)
{
let naive = chrono::NaiveDateTime::parse_from_str(
value,
"%Y-%m-%dT%H:%M:%S",
)?;
return Ok(
naive
.and_utc()
);
}
Err(anyhow!("activity contains no usable start_date"))
}
pub fn icu_intervals(activity: &Value) -> Vec<Value> {
activity
.get("icu_intervals")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
pub fn matching_interval(
activity: &Value,
track_index: usize,
) -> Option<Value> {
let intervals = Self::icu_intervals(activity);
intervals
.into_iter()
.filter(|interval| {
let start = interval
.get("start_index")
.and_then(Value::as_u64);
let end = interval
.get("end_index")
.and_then(Value::as_u64);
match (start, end) {
(Some(start), Some(end)) =>
(start as usize) <= track_index
&& track_index < end as usize,
_ => false,
}
})
.min_by_key(|interval| {
let start = interval
.get("start_index")
.and_then(Value::as_u64)
.unwrap_or(u64::MAX);
let end = interval
.get("end_index")
.and_then(Value::as_u64)
.unwrap_or(u64::MAX);
end.saturating_sub(start)
})
}
pub fn interval_metrics(
interval: &Value,
) -> (Option<f64>, Option<f64>, Option<i64>) {
let watts = interval
.get("average_watts")
.and_then(Value::as_f64);
let hr = interval
.get("average_heartrate")
.and_then(Value::as_f64);
let id = interval
.get("id")
.and_then(Value::as_i64);
(watts, hr, id)
}
pub fn token_data(
response: &Value,
) -> Result<(String, String, String, String)> {
let token = response
.get("access_token")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("OAuth response has no access_token"))?;
let scope = response
.get("scope")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let athlete = response
.get("athlete")
.ok_or_else(|| anyhow!("OAuth response has no athlete"))?;
let athlete_id = athlete
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("OAuth response has no athlete.id"))?;
let athlete_name = athlete
.get("name")
.and_then(Value::as_str)
.unwrap_or(athlete_id);
Ok((
token.to_owned(),
scope,
athlete_id.to_owned(),
athlete_name.to_owned(),
))
}
pub fn _json_example() -> Value {
json!({
"scope": "ACTIVITY:READ"
})
}
}