game_manager_lib\services/
steam.rs1use crate::constants::{
7 REVIEW_API_URL, STEAMSPY_API_URL, STEAM_REVIEWS_TIMEOUT_SECS, STEAM_STORE_API_URL,
8 STEAM_STORE_TIMEOUT_SECS, USER_AGENT_STEAM,
9};
10use crate::utils::http_client::HTTP_CLIENT;
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use std::time::Duration;
14
15#[derive(Debug, Deserialize, Serialize)]
19pub struct SteamGame {
20 pub appid: u32,
21 pub name: String,
22 pub playtime_forever: i32, pub img_icon_url: Option<String>,
24 #[serde(default)]
25 pub rtime_last_played: i64,
26}
27
28#[derive(Debug, Deserialize, Serialize)]
29struct SteamResponseData {
30 game_count: u32,
31 games: Vec<SteamGame>,
32}
33
34#[derive(Debug, Deserialize, Serialize)]
35struct SteamApiResponse {
36 response: SteamResponseData,
37}
38
39#[derive(Debug, Deserialize, Serialize, Clone)]
41pub struct SteamAchievement {
42 pub apiname: String,
43 pub achieved: i32,
44 pub unlocktime: i64,
45 pub name: Option<String>,
46 pub description: Option<String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct PlayerStats {
51 achievements: Option<Vec<SteamAchievement>>,
52}
53
54#[derive(Debug, Deserialize)]
55struct PlayerStatsResponse {
56 playerstats: PlayerStats,
57}
58
59#[derive(Debug, Deserialize)]
60struct RecentGamesResponse {
61 response: RecentGamesData,
62}
63
64#[derive(Debug, Deserialize)]
65struct RecentGamesData {
66 games: Option<Vec<SteamGame>>,
67}
68
69pub async fn list_steam_games(api_key: &str, steam_id: &str) -> Result<Vec<SteamGame>, String> {
71 let url = format!(
72 "https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key={}&steamid={}&format=json&include_appinfo=true&include_played_free_games=true",
73 api_key, steam_id
74 );
75
76 let res = HTTP_CLIENT
77 .get(&url)
78 .send()
79 .await
80 .map_err(|e| e.to_string())?;
81
82 if !res.status().is_success() {
83 return Err(format!("Erro Steam API (OwnedGames): {}", res.status()));
84 }
85
86 let api_data: SteamApiResponse = res.json().await.map_err(|e| format!("JSON Error: {}", e))?;
87 Ok(api_data.response.games)
88}
89
90pub async fn get_recently_played_games(
92 api_key: &str,
93 steam_id: &str,
94) -> Result<Vec<SteamGame>, String> {
95 let url = format!(
96 "https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v0001/?key={}&steamid={}&format=json&count=10",
97 api_key, steam_id
98 );
99
100 let res = crate::utils::http_client::HTTP_CLIENT
101 .get(&url)
102 .send()
103 .await
104 .map_err(|e| e.to_string())?;
105
106 if !res.status().is_success() {
107 return Err(format!("Erro Steam Recent Games: {}", res.status()));
108 }
109
110 let data: RecentGamesResponse = res.json().await.map_err(|e| e.to_string())?;
111 Ok(data.response.games.unwrap_or_default())
112}
113
114pub async fn get_player_achievements(
116 api_key: &str,
117 steam_id: &str,
118 app_id: u32,
119) -> Result<Vec<SteamAchievement>, String> {
120 let url = format!(
122 "https://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?appid={}&key={}&steamid={}&l=brazilian",
123 app_id, api_key, steam_id
124 );
125
126 let res = crate::utils::http_client::HTTP_CLIENT
127 .get(&url)
128 .send()
129 .await
130 .map_err(|e| e.to_string())?;
131
132 if !res.status().is_success() {
134 return Ok(vec![]);
135 }
136
137 let data: Result<PlayerStatsResponse, _> = res.json().await;
138 match data {
139 Ok(d) => Ok(d.playerstats.achievements.unwrap_or_default()),
140 Err(_) => Ok(vec![]), }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct SteamStoreData {
149 pub name: String,
150 pub is_free: bool,
151 pub short_description: String,
152 pub header_image: String,
153 pub website: Option<String>,
154 pub release_date: Option<String>,
155 pub content_descriptors: ContentDescriptors,
156 pub categories: Vec<Category>,
157 pub genres: Vec<Genre>,
158 pub required_age: u32,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ContentDescriptors {
163 pub ids: Vec<u32>,
164 pub notes: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct Category {
169 pub id: u32,
170 pub description: String,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct Genre {
175 pub id: String,
176 pub description: String,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct SteamReviewSummary {
181 pub review_score: u32,
182 pub review_score_desc: String,
183 pub total_positive: u32,
184 pub total_negative: u32,
185 pub total_reviews: u32,
186}
187
188pub async fn get_app_details(app_id: &str) -> Result<Option<SteamStoreData>, String> {
191 let url = format!(
193 "{}?appids={}&l=brazilian&filters=basic,content_descriptors,categories,genres,release_date",
194 STEAM_STORE_API_URL, app_id
195 );
196
197 let response = HTTP_CLIENT
198 .get(&url)
199 .timeout(Duration::from_secs(STEAM_STORE_TIMEOUT_SECS))
200 .send()
201 .await
202 .map_err(|e| format!("Erro requisição Steam Store: {}", e))?;
203
204 if !response.status().is_success() {
205 return Err(format!("Steam Store API Error: {}", response.status()));
206 }
207
208 let json: Value = response.json().await.map_err(|e| e.to_string())?;
209
210 if let Some(app_wrapper) = json.get(app_id) {
212 if let Some(success) = app_wrapper.get("success").and_then(|v| v.as_bool()) {
213 if success {
214 if let Some(data) = app_wrapper.get("data") {
215 let name = data
216 .get("name")
217 .and_then(|v| v.as_str())
218 .unwrap_or("")
219 .to_string();
220 let is_free = data
221 .get("is_free")
222 .and_then(|v| v.as_bool())
223 .unwrap_or(false);
224 let short_description = data
225 .get("short_description")
226 .and_then(|v| v.as_str())
227 .unwrap_or("")
228 .to_string();
229 let header_image = data
230 .get("header_image")
231 .and_then(|v| v.as_str())
232 .unwrap_or("")
233 .to_string();
234 let website = data
235 .get("website")
236 .and_then(|v| v.as_str())
237 .map(|s| s.to_string());
238
239 let release_date = data
240 .get("release_date")
241 .and_then(|v| v.get("date"))
242 .and_then(|v| v.as_str())
243 .map(|s| s.to_string());
244
245 let required_age = data
246 .get("required_age")
247 .and_then(|v| v.as_u64())
248 .unwrap_or(0) as u32;
249
250 let content_descriptors: ContentDescriptors = serde_json::from_value(
251 data.get("content_descriptors")
252 .cloned()
253 .unwrap_or(json!({"ids": [], "notes": null})),
254 )
255 .unwrap_or(ContentDescriptors {
256 ids: vec![],
257 notes: None,
258 });
259
260 let categories: Vec<Category> = serde_json::from_value(
261 data.get("categories").cloned().unwrap_or(json!([])),
262 )
263 .unwrap_or_default();
264
265 let genres: Vec<Genre> =
266 serde_json::from_value(data.get("genres").cloned().unwrap_or(json!([])))
267 .unwrap_or_default();
268
269 return Ok(Some(SteamStoreData {
270 name,
271 is_free,
272 short_description,
273 header_image,
274 website,
275 release_date,
276 content_descriptors,
277 categories,
278 genres,
279 required_age,
280 }));
281 }
282 }
283 }
284 }
285
286 Ok(None)
287}
288
289pub async fn get_app_reviews(app_id: &str) -> Result<Option<SteamReviewSummary>, String> {
291 let url = format!(
292 "{}/{}?json=1&language=all&purchase_type=all",
293 REVIEW_API_URL, app_id
294 );
295
296 let response = HTTP_CLIENT
297 .get(&url)
298 .header("User-Agent", "Valve/Steam HTTP Client 1.0")
299 .send()
300 .await
301 .map_err(|e| e.to_string())?;
302
303 let json: Value = response.json().await.map_err(|e| e.to_string())?;
304
305 if let Some(success) = json.get("success").and_then(|v| v.as_i64()) {
306 if success == 1 {
307 if let Some(summary) = json.get("query_summary") {
308 let total_reviews = summary
309 .get("total_reviews")
310 .and_then(|v| v.as_u64())
311 .unwrap_or(0) as u32;
312
313 let review_score_desc = summary
314 .get("review_score_desc")
315 .and_then(|v| v.as_str())
316 .unwrap_or("No Reviews")
317 .to_string();
318
319 return Ok(Some(SteamReviewSummary {
320 review_score: summary
321 .get("review_score")
322 .and_then(|v| v.as_u64())
323 .unwrap_or(0) as u32,
324 review_score_desc,
325 total_positive: summary
326 .get("total_positive")
327 .and_then(|v| v.as_u64())
328 .unwrap_or(0) as u32,
329 total_negative: summary
330 .get("total_negative")
331 .and_then(|v| v.as_u64())
332 .unwrap_or(0) as u32,
333 total_reviews,
334 }));
335 }
336 }
337 }
338
339 Ok(None)
340}
341
342pub fn detect_adult_content(data: &SteamStoreData) -> (bool, Vec<String>) {
348 let mut flags = Vec::new();
349 let mut is_explicit = false;
350
351 for id in &data.content_descriptors.ids {
353 match id {
354 2 => {
356 is_explicit = true;
357 flags.push("sexual content".to_string());
358 }
359
360 3 => {
362 is_explicit = true;
363 flags.push("nudity".to_string());
364 }
365
366 1 => flags.push("violence".to_string()),
368 4 => flags.push("gore".to_string()),
369 5 => flags.push("adult themes".to_string()),
370
371 _ => {}
372 }
373 }
374
375 if let Some(notes) = &data.content_descriptors.notes {
377 let notes_lower = notes.to_lowercase();
378
379 let explicit_keywords = [
380 "adult only sexual content",
381 "explicit sexual",
382 "pornographic",
383 "sexual acts",
384 "graphic sexual",
385 ];
386
387 for keyword in explicit_keywords {
388 if notes_lower.contains(keyword) {
389 is_explicit = true;
390 flags.push(keyword.to_string());
391 }
392 }
393 }
394
395 for genre in &data.genres {
397 let desc = genre.description.to_lowercase();
398
399 let explicit_genre_keywords = [
400 "hentai",
401 "nsfw",
402 "eroge",
403 "porn",
404 "sexual content",
405 "adult only",
406 ];
407
408 for keyword in explicit_genre_keywords {
409 if desc.contains(keyword) {
410 is_explicit = true;
411 flags.push(keyword.to_string());
412 }
413 }
414 }
415
416 flags.sort();
418 flags.dedup();
419
420 (is_explicit, flags)
421}
422
423#[derive(Debug, Deserialize)]
426struct SteamSpyResponse {
427 median_forever: u32,
428}
429
430pub async fn get_median_playtime(app_id: &str) -> Result<Option<u32>, String> {
432 let url = format!("{}?request=appdetails&appid={}", STEAMSPY_API_URL, app_id);
433
434 let response = HTTP_CLIENT
435 .get(&url)
436 .header("User-Agent", USER_AGENT_STEAM)
437 .timeout(Duration::from_secs(STEAM_REVIEWS_TIMEOUT_SECS))
438 .send()
439 .await
440 .map_err(|e| e.to_string())?;
441
442 if !response.status().is_success() {
443 return Ok(None);
444 }
445
446 match response.json::<SteamSpyResponse>().await {
448 Ok(data) => {
449 let median_hours = data.median_forever / 60;
451 if median_hours > 0 {
453 Ok(Some(median_hours))
454 } else {
455 Ok(None)
456 }
457 }
458 Err(_) => Ok(None),
459 }
460}