Skip to main content

hinge_rs/client/
recommendations.rs

1use super::HingeClient;
2use crate::errors::HingeError;
3use crate::models::{
4    RecommendationSubject, RecommendationsFeed, RecommendationsResponse, RecsV2Params,
5};
6use crate::storage::Storage;
7use reqwest::StatusCode;
8use std::collections::{HashMap, HashSet};
9use std::time::{Duration, Instant};
10use tokio::time::sleep;
11
12impl<S: Storage + Clone> HingeClient<S> {
13    pub async fn get_recommendations(&mut self) -> Result<RecommendationsResponse, HingeError> {
14        self.get_recommendations_v2_params(RecsV2Params {
15            new_here: false,
16            active_today: false,
17        })
18        .await
19    }
20
21    pub async fn get_recommendations_v2_params(
22        &mut self,
23        params: RecsV2Params,
24    ) -> Result<RecommendationsResponse, HingeError> {
25        let url = format!("{}/rec/v2", self.settings.base_url);
26        let identity_id = self
27            .hinge_auth
28            .as_ref()
29            .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
30            .identity_id
31            .clone();
32
33        #[derive(serde::Serialize)]
34        #[serde(rename_all = "camelCase")]
35        struct Body {
36            player_id: String,
37            new_here: bool,
38            active_today: bool,
39        }
40
41        let body = Body {
42            player_id: identity_id,
43            new_here: params.new_here,
44            active_today: params.active_today,
45        };
46        let body_json =
47            serde_json::to_value(&body).map_err(|e| HingeError::Serde(e.to_string()))?;
48
49        let fetch_count = self.recs_fetch_config.multi_fetch_count.max(1);
50        let min_delay = Duration::from_millis(self.recs_fetch_config.request_delay_ms);
51        let mut aggregated: Option<RecommendationsResponse> = None;
52        let mut completed_calls = 0usize;
53        let mut rate_limit_attempts = 0usize;
54        let max_rate_limit_retries = self.recs_fetch_config.rate_limit_retries;
55        let base_backoff_ms = self.recs_fetch_config.rate_limit_backoff_ms.max(1);
56
57        while completed_calls < fetch_count {
58            if let Some(last_call) = self.last_recs_v2_call {
59                let elapsed = last_call.elapsed();
60                if elapsed < min_delay {
61                    sleep(min_delay - elapsed).await;
62                }
63            }
64
65            let res = self.http_post(&url, &body_json).await?;
66            self.last_recs_v2_call = Some(Instant::now());
67
68            let status = res.status();
69            if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE
70            {
71                rate_limit_attempts += 1;
72                if rate_limit_attempts > max_rate_limit_retries {
73                    log::warn!(
74                        "[rec/v2] rate limited after {} retries; returning aggregated data",
75                        rate_limit_attempts
76                    );
77                    break;
78                }
79
80                let exponent = rate_limit_attempts.saturating_sub(1) as u32;
81                let factor = 1u64
82                    .checked_shl(exponent)
83                    .filter(|&v| v > 0)
84                    .unwrap_or(u64::MAX);
85                let backoff = base_backoff_ms.saturating_mul(factor);
86                log::warn!(
87                    "[rec/v2] rate limited (status {}). backing off {} ms before retry (attempt {}/{})",
88                    status,
89                    backoff,
90                    rate_limit_attempts,
91                    max_rate_limit_retries
92                );
93                sleep(Duration::from_millis(backoff)).await;
94                continue;
95            }
96
97            rate_limit_attempts = 0;
98            let response = self.parse_response::<RecommendationsResponse>(res).await?;
99            if let Some(existing) = aggregated.as_mut() {
100                merge_recommendation_responses(existing, response);
101            } else {
102                aggregated = Some(response);
103            }
104            completed_calls += 1;
105        }
106
107        let mut out = aggregated.unwrap_or_else(|| RecommendationsResponse {
108            feeds: Vec::new(),
109            active_pills: None,
110            cache_control: None,
111        });
112        normalize_recommendations_response(&mut out);
113
114        if self.auto_persist {
115            match self.recs_cache_path() {
116                Some(path) => {
117                    let _ = self.apply_recommendations_and_save(&mut out, Some(&path));
118                }
119                None => {
120                    let _ = self.apply_recommendations_and_save(&mut out, None);
121                }
122            }
123        }
124        Ok(out)
125    }
126
127    pub fn apply_recommendations_and_save(
128        &mut self,
129        recs: &mut RecommendationsResponse,
130        path: Option<&str>,
131    ) -> Result<(), HingeError> {
132        for feed in &mut recs.feeds {
133            for subj in &mut feed.subjects {
134                if subj.origin.is_none() {
135                    subj.origin = Some(feed.origin.clone());
136                }
137                self.recommendations
138                    .entry(subj.subject_id.clone())
139                    .or_insert_with(|| subj.clone());
140            }
141        }
142        if let Some(p) = path {
143            self.save_recommendations(p)?;
144        }
145        Ok(())
146    }
147
148    pub async fn repeat_profiles(&mut self) -> Result<serde_json::Value, HingeError> {
149        let url = format!("{}/user/repeat", self.settings.base_url);
150        let res = self.http_get(&url).await?;
151        let body = self.parse_response(res).await?;
152        if self.auto_persist
153            && let Some(path) = self.recs_cache_path()
154        {
155            let _ = self.save_recommendations(&path);
156        }
157        Ok(body)
158    }
159
160    pub fn save_recommendations(&self, path: &str) -> Result<(), HingeError> {
161        let data = serde_json::to_string_pretty(&self.recommendations)
162            .map_err(|e| HingeError::Serde(e.to_string()))?;
163        self.storage
164            .write_string(path, &data)
165            .map_err(|e| HingeError::Storage(e.to_string()))?;
166        Ok(())
167    }
168
169    pub fn load_recommendations(&mut self, path: &str) -> Result<(), HingeError> {
170        if !self.storage.exists(path) {
171            return Ok(());
172        }
173        let data = self
174            .storage
175            .read_to_string(path)
176            .map_err(|e| HingeError::Storage(e.to_string()))?;
177        self.recommendations =
178            serde_json::from_str(&data).map_err(|e| HingeError::Serde(e.to_string()))?;
179        Ok(())
180    }
181
182    pub fn remove_recommendation(&mut self, subject_id: &str) {
183        self.recommendations.remove(subject_id);
184    }
185}
186
187fn merge_recommendation_responses(
188    base: &mut RecommendationsResponse,
189    mut additional: RecommendationsResponse,
190) {
191    let mut feed_index: HashMap<String, usize> = HashMap::new();
192    for (idx, feed) in base.feeds.iter().enumerate() {
193        feed_index.insert(feed.origin.clone(), idx);
194    }
195
196    for feed in additional.feeds.drain(..) {
197        if let Some(&idx) = feed_index.get(&feed.origin) {
198            let existing_feed = &mut base.feeds[idx];
199            let mut seen: HashSet<String> = existing_feed
200                .subjects
201                .iter()
202                .map(|s| s.subject_id.clone())
203                .collect();
204            for mut subj in feed.subjects {
205                if seen.insert(subj.subject_id.clone()) {
206                    if subj.origin.is_none() {
207                        subj.origin = Some(feed.origin.clone());
208                    }
209                    existing_feed.subjects.push(subj);
210                }
211            }
212            if existing_feed.permission.is_none() {
213                existing_feed.permission = feed.permission;
214            }
215            if existing_feed.preview.is_none() {
216                existing_feed.preview = feed.preview;
217            }
218        } else {
219            let mut new_feed = feed;
220            for subj in &mut new_feed.subjects {
221                if subj.origin.is_none() {
222                    subj.origin = Some(new_feed.origin.clone());
223                }
224            }
225            feed_index.insert(new_feed.origin.clone(), base.feeds.len());
226            base.feeds.push(new_feed);
227        }
228    }
229
230    match (&mut base.active_pills, additional.active_pills) {
231        (Some(existing), Some(mut incoming)) => {
232            let mut seen: HashSet<String> = existing.iter().map(|pill| pill.id.clone()).collect();
233            for pill in incoming.drain(..) {
234                if seen.insert(pill.id.clone()) {
235                    existing.push(pill);
236                }
237            }
238        }
239        (None, Some(pills)) => base.active_pills = Some(pills),
240        _ => {}
241    }
242
243    if base.cache_control.is_none() && additional.cache_control.is_some() {
244        base.cache_control = additional.cache_control;
245    }
246}
247
248fn normalize_recommendations_response(response: &mut RecommendationsResponse) {
249    let mut ordered_subjects: Vec<RecommendationSubject> = Vec::new();
250    let mut seen = HashSet::new();
251
252    for feed in &response.feeds {
253        for subj in &feed.subjects {
254            if seen.insert(subj.subject_id.clone()) {
255                let mut clone = subj.clone();
256                if clone.origin.is_none() {
257                    clone.origin = Some(feed.origin.clone());
258                }
259                ordered_subjects.push(clone);
260            }
261        }
262    }
263
264    let (permission, preview) = response
265        .feeds
266        .first()
267        .map(|feed| (feed.permission.clone(), feed.preview.clone()))
268        .unwrap_or((None, None));
269    let origin = response
270        .feeds
271        .first()
272        .map(|feed| feed.origin.clone())
273        .unwrap_or_else(|| "combined".to_string());
274
275    response.feeds = vec![RecommendationsFeed {
276        id: 0,
277        origin,
278        subjects: ordered_subjects,
279        permission,
280        preview,
281    }];
282}