This commit is contained in:
Jonas Rabenstein 2026-08-12 14:24:50 +02:00
commit eda8e76a20
4 changed files with 481 additions and 218 deletions

View file

@ -1,7 +1,7 @@
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, NaiveDateTime, Utc};
use reqwest::{Client, StatusCode};
use serde_json::{Value, json};
use reqwest::Client;
use serde_json::Value;
use crate::config::Config;
@ -9,13 +9,28 @@ use crate::config::Config;
pub struct IntervalsClient {
client: Client,
config: Config,
api_key: Option<String>,
}
impl IntervalsClient {
pub fn new(config: Config) -> Self {
let api_key = config.intervals_api_key.clone();
Self {
client: Client::new(),
config,
api_key,
}
}
/// Create a client using an explicitly supplied personal API key.
///
/// Primarily used by /dev/sync/{api_key}/{athlete_id}.
pub fn with_api_key(config: Config, api_key: impl Into<String>) -> Self {
Self {
client: Client::new(),
config,
api_key: Some(api_key.into()),
}
}
@ -24,7 +39,9 @@ impl IntervalsClient {
}
fn api_key(&self) -> Result<&str> {
self.config.require_api_key()
self.api_key
.as_deref()
.context("Intervals.icu API key is not configured")
}
fn api_url(&self, path: &str) -> String {
@ -41,12 +58,13 @@ impl IntervalsClient {
async fn get_json(&self, url: String) -> Result<Value> {
let response = self
.authenticated(self.client.get(url))?
.authenticated(self.client.get(&url))?
.send()
.await
.context("Intervals.icu request failed")?;
let status = response.status();
let body = response
.text()
.await
@ -60,40 +78,103 @@ impl IntervalsClient {
.with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body))
}
/// Get the athlete belonging to the configured API key.
///
/// GET /api/v1/athlete/0
pub async fn owner(&self) -> Result<Value> {
let url = self.api_url("athlete/0");
self.get_json(url).await
}
/// Get a specific athlete.
pub async fn athlete(&self, athlete_id: &str) -> Result<Value> {
let url = self.api_url(&format!("athlete/{athlete_id}"));
self.get_json(url).await
}
/// Get a single activity.
///
/// Uses:
/// GET /api/v1/activity/{id}
pub async fn activity(&self, activity_id: &str) -> Result<Value> {
let url = self.api_url(&format!("activity/{activity_id}"));
let url = self.api_url(&format!("activity/{activity_id}?intervals=true"));
self.get_json(url).await
}
/// Get activity streams.
///
/// Uses:
/// GET /api/v1/activity/{id}/streams.json
///
/// The response is normally an array of stream objects:
///
/// [
/// {"type":"time","data":[...]},
/// {"type":"latlng","data":[...]},
/// ...
/// ]
/// We explicitly request the streams needed for GPS processing.
pub async fn streams(&self, activity_id: &str) -> Result<Value> {
let url = self.api_url(&format!("activity/{activity_id}/streams.json"));
let url = self.api_url(&format!(
"activity/{activity_id}/streams.json?types=time,latlng"
));
self.get_json(url).await
}
/// Fetch the athlete's activities.
///
/// `0` means the athlete belonging to the API key.
pub async fn activities(&self, oldest: &str, newest: &str) -> Result<Value> {
let athlete_id = &self.config.intervals_athlete_id;
/// Find a stream object by type.
pub fn find_stream<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Value> {
// Normal API response:
//
// [
// {
// "type": "time",
// "data": [...]
// },
// {
// "type": "latlng",
// "data": [...],
// "data2": [...]
// }
// ]
if let Some(array) = streams.as_array() {
return array
.iter()
.find(|stream| stream.get("type").and_then(Value::as_str) == Some(stream_type));
}
// Also tolerate:
//
// {
// "time": {...},
// "latlng": {...}
// }
streams.get(stream_type)
}
/// Get the `data` array of a normal stream.
pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
let stream = Self::find_stream(streams, stream_type)?;
stream.get("data").and_then(Value::as_array)
}
/// Get the latitude and longitude arrays from the
/// Intervals.icu `latlng` stream.
///
/// Intervals.icu represents latlng as:
///
/// data = latitude
/// data2 = longitude
///
pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec<Value>, &'a Vec<Value>)> {
let stream = Self::find_stream(streams, "latlng")?;
let lat = stream.get("data")?.as_array()?;
let lon = stream.get("data2")?.as_array()?;
Some((lat, lon))
}
/// Fetch an athlete's activities.
///
/// `athlete_id = "0"` means the athlete belonging to
/// the API key.
pub async fn activities(&self, athlete_id: &str, oldest: &str, newest: &str) -> Result<Value> {
let url = format!(
"{}?oldest={}&newest={}",
self.api_url(&format!("athlete/{athlete_id}/activities")),
@ -104,27 +185,6 @@ impl IntervalsClient {
self.get_json(url).await
}
/// Return a stream's values independent of whether the API
/// returned an object or an array of stream objects.
pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
// Current API format: array of streams.
if let Some(array) = streams.as_array() {
for stream in array {
if stream.get("type").and_then(Value::as_str) == Some(stream_type) {
return stream.get("data").and_then(Value::as_array);
}
}
}
// Also support a dictionary-like response:
//
// {
// "time": [...],
// "latlng": [...]
// }
streams.get(stream_type).and_then(Value::as_array)
}
/// Extract the activity start timestamp.
pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> {
let value = activity
@ -142,9 +202,6 @@ impl IntervalsClient {
/// Try to find an Intervals.icu interval corresponding
/// to a GPS stream index.
///
/// The exact interval representation has changed over
/// time, so this deliberately supports the common forms.
pub fn matching_interval(activity: &Value, track_index: usize) -> Option<Value> {
let intervals = activity.get("icu_intervals")?;
@ -158,6 +215,7 @@ impl IntervalsClient {
let end = interval.get("end_index").or_else(|| interval.get("end"));
let start = start.and_then(Value::as_u64)?;
let end = end.and_then(Value::as_u64)?;
if (start as usize) <= track_index && track_index <= end as usize {
@ -168,8 +226,7 @@ impl IntervalsClient {
None
}
/// OAuth methods are intentionally retained so we can
/// re-enable multi-user OAuth later.
/// OAuth methods retained for the normal multi-user flow.
pub fn oauth_authorize_url(&self, state: &str) -> Result<String> {
let client_id = self
.config
@ -219,6 +276,7 @@ impl IntervalsClient {
.context("OAuth token request failed")?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
@ -252,9 +310,14 @@ impl IntervalsClient {
let athlete_id = athlete
.get("id")
.and_then(Value::as_str)
.or_else(|| athlete.get("id").and_then(Value::as_i64).map(|_| ""))
.unwrap_or("")
.to_string();
.map(str::to_string)
.or_else(|| {
athlete
.get("id")
.and_then(Value::as_i64)
.map(|id| id.to_string())
})
.unwrap_or_default();
let display_name = athlete
.get("name")