game_manager_lib/services/integration/
steam_api.rs1use crate::constants::{
7 REVIEW_API_URL, STEAM_STORE_API_URL, STEAM_STORE_SEARCH_URL, STEAM_STORE_TIMEOUT_SECS,
8 USER_AGENT_BROWSER, 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> {
79 let url = format!(
80 "https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key={}&steamid={}&format=json&include_appinfo=true&include_played_free_games=true",
81 api_key, steam_id
82 );
83
84 let res = HTTP_CLIENT
85 .get(&url)
86 .send()
87 .await
88 .map_err(|e| e.to_string())?;
89
90 if !res.status().is_success() {
91 return Err(format!("Erro Steam API (OwnedGames): {}", res.status()));
92 }
93
94 let api_data: SteamApiResponse = res.json().await.map_err(|e| format!("JSON Error: {}", e))?;
95 Ok(api_data.response.games)
96}
97
98pub async fn get_recently_played_games(
100 api_key: &str,
101 steam_id: &str,
102) -> Result<Vec<SteamGame>, String> {
103 let url = format!(
104 "https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v0001/?key={}&steamid={}&format=json&count=10",
105 api_key, steam_id
106 );
107
108 let res = crate::utils::http_client::HTTP_CLIENT
109 .get(&url)
110 .send()
111 .await
112 .map_err(|e| e.to_string())?;
113
114 if !res.status().is_success() {
115 return Err(format!("Erro Steam Recent Games: {}", res.status()));
116 }
117
118 let data: RecentGamesResponse = res.json().await.map_err(|e| e.to_string())?;
119 Ok(data.response.games.unwrap_or_default())
120}
121
122pub async fn get_player_achievements(
124 api_key: &str,
125 steam_id: &str,
126 app_id: u32,
127) -> Result<Vec<SteamAchievement>, String> {
128 let url = format!(
130 "https://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?appid={}&key={}&steamid={}&l=brazilian",
131 app_id, api_key, steam_id
132 );
133
134 let res = crate::utils::http_client::HTTP_CLIENT
135 .get(&url)
136 .send()
137 .await
138 .map_err(|e| e.to_string())?;
139
140 if !res.status().is_success() {
142 return Ok(vec![]);
143 }
144
145 let data: Result<PlayerStatsResponse, _> = res.json().await;
146 match data {
147 Ok(d) => Ok(d.playerstats.achievements.unwrap_or_default()),
148 Err(_) => Ok(vec![]), }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct SteamStoreData {
157 pub name: String,
158 pub is_free: bool,
159 pub short_description: String,
160 pub header_image: String,
161 pub website: Option<String>,
162 pub release_date: Option<String>,
163 pub content_descriptors: ContentDescriptors,
164 pub categories: Vec<Category>,
165 pub genres: Vec<Genre>,
166 pub required_age: u32,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ContentDescriptors {
171 pub ids: Vec<u32>,
172 pub notes: Option<String>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct Category {
177 pub id: u32,
178 pub description: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct Genre {
183 pub id: String,
184 pub description: String,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SteamReviewSummary {
189 pub review_score: u32,
190 pub review_score_desc: String,
191 pub total_positive: u32,
192 pub total_negative: u32,
193 pub total_reviews: u32,
194}
195
196#[derive(Debug, Deserialize)]
203pub struct SteamSearchItem {
204 pub id: u32,
205 pub name: String,
206 #[serde(rename = "type")]
207 pub item_type: String,
208}
209
210#[derive(Debug, Deserialize)]
211struct SteamSearchResponse {
212 items: Vec<SteamSearchItem>,
213}
214
215pub async fn search_app_by_name(name: &str) -> Result<Vec<SteamSearchItem>, String> {
225 crate::services::rate_limiter::STEAM_LIMITER
226 .run(|| async {
227 let url = format!(
228 "{}?term={}&l=english&cc=BR",
229 STEAM_STORE_SEARCH_URL,
230 urlencoding::encode(name)
231 );
232
233 let res = HTTP_CLIENT
234 .get(&url)
235 .header("User-Agent", USER_AGENT_BROWSER)
236 .timeout(Duration::from_secs(STEAM_STORE_TIMEOUT_SECS))
237 .send()
238 .await
239 .map_err(|e| format!("Erro requisição Steam Store Search: {}", e))?;
240
241 if !res.status().is_success() {
242 return Err(format!("Steam Store Search API Error: {}", res.status()));
243 }
244
245 let data: SteamSearchResponse = res
246 .json()
247 .await
248 .map_err(|e| format!("Erro ao parsear resposta Steam Store Search: {}", e))?;
249
250 Ok(data
251 .items
252 .into_iter()
253 .filter(|item| item.item_type != "sub")
254 .collect())
255 })
256 .await
257}
258
259pub async fn get_app_details(app_id: &str) -> Result<Option<SteamStoreData>, String> {
262 crate::services::rate_limiter::STEAM_LIMITER.run(|| async {
263 let url = format!(
265 "{}?appids={}&filters=basic,content_descriptors,categories,genres,release_date",
266 STEAM_STORE_API_URL, app_id
267 );
268
269 let response = HTTP_CLIENT
270 .get(&url)
271 .timeout(Duration::from_secs(STEAM_STORE_TIMEOUT_SECS))
272 .send()
273 .await
274 .map_err(|e| format!("Erro requisição Steam Store: {}", e))?;
275
276 if !response.status().is_success() {
277 return Err(format!("Steam Store API Error: {}", response.status()));
278 }
279
280 let json: Value = response.json().await.map_err(|e| e.to_string())?;
281
282 if let Some(app_wrapper) = json.get(app_id) {
284 if let Some(success) = app_wrapper.get("success").and_then(|v| v.as_bool()) {
285 if success {
286 if let Some(data) = app_wrapper.get("data") {
287 let name = data
288 .get("name")
289 .and_then(|v| v.as_str())
290 .unwrap_or("")
291 .to_string();
292 let is_free = data
293 .get("is_free")
294 .and_then(|v| v.as_bool())
295 .unwrap_or(false);
296 let short_description = data
297 .get("short_description")
298 .and_then(|v| v.as_str())
299 .unwrap_or("")
300 .to_string();
301 let header_image = data
302 .get("header_image")
303 .and_then(|v| v.as_str())
304 .unwrap_or("")
305 .to_string();
306 let website = data
307 .get("website")
308 .and_then(|v| v.as_str())
309 .map(|s| s.to_string());
310 let release_date = data
311 .get("release_date")
312 .and_then(|v| v.get("date"))
313 .and_then(|v| v.as_str())
314 .map(|s| s.to_string());
315 let required_age = data
316 .get("required_age")
317 .and_then(|v| v.as_u64())
318 .unwrap_or(0) as u32;
319 let content_descriptors: ContentDescriptors = serde_json::from_value(
320 data.get("content_descriptors")
321 .cloned()
322 .unwrap_or(json!({"ids": [], "notes": null})),
323 )
324 .unwrap_or(ContentDescriptors {
325 ids: vec![],
326 notes: None,
327 });
328 let categories: Vec<Category> = serde_json::from_value(
329 data.get("categories").cloned().unwrap_or(json!([])),
330 )
331 .unwrap_or_default();
332 let genres: Vec<Genre> =
333 serde_json::from_value(data.get("genres").cloned().unwrap_or(json!([])))
334 .unwrap_or_default();
335
336 return Ok(Some(SteamStoreData {
337 name,
338 is_free,
339 short_description,
340 header_image,
341 website,
342 release_date,
343 content_descriptors,
344 categories,
345 genres,
346 required_age,
347 }));
348 }
349 }
350 }
351 }
352
353 Ok(None)
354 }).await
355}
356
357pub async fn get_app_reviews(app_id: &str) -> Result<Option<SteamReviewSummary>, String> {
359 crate::services::rate_limiter::STEAM_LIMITER.run(|| async {
360 let url = format!(
361 "{}/{}?json=1&language=all&purchase_type=all",
362 REVIEW_API_URL, app_id
363 );
364
365 let response = HTTP_CLIENT
366 .get(&url)
367 .header("User-Agent", USER_AGENT_STEAM)
368 .send()
369 .await
370 .map_err(|e| e.to_string())?;
371
372 let json: Value = response.json().await.map_err(|e| e.to_string())?;
373
374 if let Some(success) = json.get("success").and_then(|v| v.as_i64()) {
375 if success == 1 {
376 if let Some(summary) = json.get("query_summary") {
377 let total_reviews = summary
378 .get("total_reviews")
379 .and_then(|v| v.as_u64())
380 .unwrap_or(0) as u32;
381 let review_score_desc = summary
382 .get("review_score_desc")
383 .and_then(|v| v.as_str())
384 .unwrap_or("No Reviews")
385 .to_string();
386
387 return Ok(Some(SteamReviewSummary {
388 review_score: summary
389 .get("review_score")
390 .and_then(|v| v.as_u64())
391 .unwrap_or(0) as u32,
392 review_score_desc,
393 total_positive: summary
394 .get("total_positive")
395 .and_then(|v| v.as_u64())
396 .unwrap_or(0) as u32,
397 total_negative: summary
398 .get("total_negative")
399 .and_then(|v| v.as_u64())
400 .unwrap_or(0) as u32,
401 total_reviews,
402 }));
403 }
404 }
405 }
406
407 Ok(None)
408 }).await
409}
410
411pub fn detect_adult_content(data: &SteamStoreData) -> (bool, Vec<String>) {
417 let mut flags = Vec::new();
418 let mut is_explicit = false;
419
420 for id in &data.content_descriptors.ids {
422 match id {
423 2 => {
425 is_explicit = true;
426 flags.push("sexual content".to_string());
427 }
428
429 3 => {
431 is_explicit = true;
432 flags.push("nudity".to_string());
433 }
434
435 1 => flags.push("violence".to_string()),
437 4 => flags.push("gore".to_string()),
438 5 => flags.push("adult themes".to_string()),
439
440 _ => {}
441 }
442 }
443
444 if let Some(notes) = &data.content_descriptors.notes {
446 let notes_lower = notes.to_lowercase();
447 let explicit_keywords = [
448 "adult only sexual content",
449 "explicit sexual",
450 "pornographic",
451 "sexual acts",
452 "graphic sexual",
453 ];
454
455 for keyword in explicit_keywords {
456 if notes_lower.contains(keyword) {
457 is_explicit = true;
458 flags.push(keyword.to_string());
459 }
460 }
461 }
462
463 for genre in &data.genres {
465 let desc = genre.description.to_lowercase();
466 let explicit_genre_keywords = [
467 "hentai",
468 "nsfw",
469 "eroge",
470 "porn",
471 "sexual content",
472 "adult only",
473 ];
474
475 for keyword in explicit_genre_keywords {
476 if desc.contains(keyword) {
477 is_explicit = true;
478 flags.push(keyword.to_string());
479 }
480 }
481 }
482
483 flags.sort();
485 flags.dedup();
486
487 (is_explicit, flags)
488}