game_manager_lib\services/
steam.rs

1//! Módulo de integração com as APIs Steam.
2//!
3//! Unifica funcionalidades da API de Usuário (autenticada) para importar biblioteca
4//! e da API da Loja (pública) para enriquecer metadados (reviews, conteúdo adulto).
5
6use 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// === API DE USUÁRIO - IMPORTAÇÃO DE BIBLIOTECA E CONQUISTAS (Requer API Key) ===
16
17/// Estrutura auxiliar para representar um jogo na biblioteca Steam.
18#[derive(Debug, Deserialize, Serialize)]
19pub struct SteamGame {
20    pub appid: u32,
21    pub name: String,
22    pub playtime_forever: i32, // Minutos
23    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/// Estrutura auxiliar para obter conquistas de um jogo.
40#[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
69/// Lista todos os jogos da biblioteca de um usuário Steam.
70pub 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
90/// Busca jogos jogados nas últimas 2 semanas
91pub 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
114/// Busca conquistas do jogador num jogo específico
115pub async fn get_player_achievements(
116    api_key: &str,
117    steam_id: &str,
118    app_id: u32,
119) -> Result<Vec<SteamAchievement>, String> {
120    // Usa l=brazilian para tentar obter os nomes traduzidos se disponíveis
121    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    // Jogos sem conquistas retornam 400 ou erro, e são tratados como lista vazia
133    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![]), // Falha no parse (jogo sem conquistas públicas)
141    }
142}
143
144//  === API DA LOJA - METADADOS, REVIEWS E CONTEÚDO ADULTO (Pública) ===
145
146/// Detalhes da loja Steam para um aplicativo (jogo).
147#[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
188/// Busca detalhes da loja (Conteúdo adulto, descrição, imagens).
189/// Retorna Option porque o jogo pode não existir na loja (removido/banido).
190pub async fn get_app_details(app_id: &str) -> Result<Option<SteamStoreData>, String> {
191    // Filtra apenas os campos necessários
192    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    // Navega no JSON dinâmico (Chave é o AppID)
211    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
289/// Busca o resumo das avaliações (Reviews)
290pub 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
342/// Detecta conteúdo explícito (sexual) baseado exclusivamente em critérios da Steam
343///
344/// Retorna:
345/// - bool: se é conteúdo explícito
346/// - Vec<String>: flags descritivas encontradas
347pub fn detect_adult_content(data: &SteamStoreData) -> (bool, Vec<String>) {
348    let mut flags = Vec::new();
349    let mut is_explicit = false;
350
351    // 1. Content Descriptors (fonte mais confiável)
352    for id in &data.content_descriptors.ids {
353        match id {
354            // Sexual Content
355            2 => {
356                is_explicit = true;
357                flags.push("sexual content".to_string());
358            }
359
360            // Nudity
361            3 => {
362                is_explicit = true;
363                flags.push("nudity".to_string());
364            }
365
366            // Informativos (não explícitos)
367            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    // 2. Notes (geralmente usadas para Adult Only Sexual Content)
376    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    // 3. Tags / gêneros como fallback fraco
396    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    // 4. Normalização
417    flags.sort();
418    flags.dedup();
419
420    (is_explicit, flags)
421}
422
423// === STEAMSPY (API não oficial) - ESTATÍSTICAS DE JOGO (Pública) ===
424
425#[derive(Debug, Deserialize)]
426struct SteamSpyResponse {
427    median_forever: u32,
428}
429
430/// Busca tempo médio de jogo no SteamSpy (em minutos)
431pub 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    // Tenta parsear. Se falhar (ex: jogo não trackeado), retorna None sem erro crítico
447    match response.json::<SteamSpyResponse>().await {
448        Ok(data) => {
449            // SteamSpy retorna em minutos, converter para horas
450            let median_hours = data.median_forever / 60;
451            // Filtra zeros (jogos sem dados ou nunca jogados)
452            if median_hours > 0 {
453                Ok(Some(median_hours))
454            } else {
455                Ok(None)
456            }
457        }
458        Err(_) => Ok(None),
459    }
460}