hinge_rs/client/
recommendations.rs1use 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}