1use std::{collections::HashMap, sync::OnceLock, time::Duration};
2
3use scraper::{Html, Selector};
4use serde_json::Value;
5
6use crate::{
7 client::SteamUser,
8 endpoint::steam_endpoint,
9 error::SteamUserError,
10 types::apps::{AppDetail, CsgoAccountStats, OwnedApp},
11};
12
13const ADHOC_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
16
17const ADHOC_TIMEOUT: Duration = Duration::from_secs(60);
19
20fn build_adhoc_client() -> Result<reqwest::Client, SteamUserError> {
22 reqwest::Client::builder().connect_timeout(ADHOC_CONNECT_TIMEOUT).timeout(ADHOC_TIMEOUT).build().map_err(SteamUserError::from)
23}
24
25static SEL_KV_LINE: OnceLock<Selector> = OnceLock::new();
26fn sel_kv_line() -> &'static Selector {
27 SEL_KV_LINE.get_or_init(|| Selector::parse(".generic_kv_table .generic_kv_line").expect("valid CSS selector"))
28}
29
30static SEL_KV_TABLE: OnceLock<Selector> = OnceLock::new();
31fn sel_kv_table() -> &'static Selector {
32 SEL_KV_TABLE.get_or_init(|| Selector::parse("table.generic_kv_table").expect("valid CSS selector"))
33}
34
35static SEL_TR: OnceLock<Selector> = OnceLock::new();
36fn sel_tr() -> &'static Selector {
37 SEL_TR.get_or_init(|| Selector::parse("tr").expect("valid CSS selector"))
38}
39
40static SEL_TH: OnceLock<Selector> = OnceLock::new();
41fn sel_th() -> &'static Selector {
42 SEL_TH.get_or_init(|| Selector::parse("th").expect("valid CSS selector"))
43}
44
45static SEL_TD: OnceLock<Selector> = OnceLock::new();
46fn sel_td() -> &'static Selector {
47 SEL_TD.get_or_init(|| Selector::parse("td").expect("valid CSS selector"))
48}
49
50static SEL_MATCH_ANCHOR: OnceLock<Selector> = OnceLock::new();
51fn sel_match_anchor() -> &'static Selector {
52 SEL_MATCH_ANCHOR.get_or_init(|| Selector::parse("a.match").expect("valid CSS selector"))
53}
54
55static SEL_MATCH_NAME: OnceLock<Selector> = OnceLock::new();
56fn sel_match_name() -> &'static Selector {
57 SEL_MATCH_NAME.get_or_init(|| Selector::parse(".match_name").expect("valid CSS selector"))
58}
59
60static SEL_MATCH_IMG: OnceLock<Selector> = OnceLock::new();
61fn sel_match_img() -> &'static Selector {
62 SEL_MATCH_IMG.get_or_init(|| Selector::parse(".match_img img").expect("valid CSS selector"))
63}
64
65static SEL_MATCH_PRICE: OnceLock<Selector> = OnceLock::new();
66fn sel_match_price() -> &'static Selector {
67 SEL_MATCH_PRICE.get_or_init(|| Selector::parse(".match_price").expect("valid CSS selector"))
68}
69
70static SEL_SEARCH_ROW: OnceLock<Selector> = OnceLock::new();
71fn sel_search_row() -> &'static Selector {
72 SEL_SEARCH_ROW.get_or_init(|| Selector::parse("a.search_result_row").expect("valid CSS selector"))
73}
74
75static SEL_SEARCH_NAME: OnceLock<Selector> = OnceLock::new();
76fn sel_search_name() -> &'static Selector {
77 SEL_SEARCH_NAME.get_or_init(|| Selector::parse(".search_name .title").expect("valid CSS selector"))
78}
79
80static SEL_SEARCH_CAPSULE_IMG: OnceLock<Selector> = OnceLock::new();
81fn sel_search_capsule_img() -> &'static Selector {
82 SEL_SEARCH_CAPSULE_IMG.get_or_init(|| Selector::parse(".search_capsule img").expect("valid CSS selector"))
83}
84
85static SEL_SEARCH_PRICE: OnceLock<Selector> = OnceLock::new();
86fn sel_search_price() -> &'static Selector {
87 SEL_SEARCH_PRICE.get_or_init(|| Selector::parse(".search_price").expect("valid CSS selector"))
88}
89
90static SEL_APP_CONFIG: OnceLock<Selector> = OnceLock::new();
91fn sel_app_config() -> &'static Selector {
92 SEL_APP_CONFIG.get_or_init(|| Selector::parse("#application_config").expect("valid CSS selector"))
93}
94
95impl SteamUser {
96 #[steam_endpoint(GET, host = Community, path = "/actions/GetOwnedApps/", kind = Read)]
116 pub async fn get_owned_apps(&self) -> Result<Vec<OwnedApp>, SteamUserError> {
117 let response: Value = self.get_path("/actions/GetOwnedApps/").send().await?.json().await?;
118
119 let apps = response.as_array().ok_or_else(|| SteamUserError::MalformedResponse("Expected an array of apps".into()))?;
120
121 let mut owned_apps = Vec::new();
122 for app in apps {
123 if let Ok(owned_app) = serde_json::from_value::<OwnedApp>(app.clone()) {
124 owned_apps.push(owned_app);
125 }
126 }
127
128 Ok(owned_apps)
129 }
130
131 #[steam_endpoint(GET, host = Store, path = "/api/appdetails", kind = Read)]
157 pub async fn get_app_detail(&self, app_ids: &[u32]) -> Result<HashMap<u32, AppDetail>, SteamUserError> {
158 if app_ids.is_empty() {
159 return Ok(HashMap::new());
160 }
161
162 let ids = app_ids.iter().map(|id| id.to_string()).collect::<Vec<String>>().join(",");
163
164 let response: Value = self.get_path("/api/appdetails").query(&[("appids", ids.as_str()), ("hl", "en")]).send().await?.json().await?;
165
166 let mut details = HashMap::new();
167 if let Some(obj) = response.as_object() {
168 for (key, val) in obj {
169 if let Ok(app_id) = key.parse::<u32>() {
170 if val["success"].as_bool().unwrap_or(false) {
171 if let Ok(detail) = serde_json::from_value::<AppDetail>(val["data"].clone()) {
172 details.insert(app_id, detail);
173 }
174 }
175 }
176 }
177 }
178
179 Ok(details)
180 }
181
182 #[steam_endpoint(GET, host = Community, path = "/my/gcpd/730/", kind = Read)]
205 pub async fn fetch_csgo_account_stats(&self) -> Result<CsgoAccountStats, SteamUserError> {
206 let html = self.my_profile_get("gcpd/730/?tab=accountmain").await?;
207 let document = Html::parse_document(&html);
208
209 let mut stats = CsgoAccountStats {
210 last_logout_csgo: None,
211 last_launch_steam_client: None,
212 start_play_csgo: None,
213 first_played_cs_franchise: None,
214 last_known_ip: None,
215 earned_service_medal: None,
216 profile_rank: None,
217 xp_to_next_rank: None,
218 anti_addiction_online_time: None,
219 };
220
221 for element in document.select(sel_kv_line()) {
223 let text = element.text().collect::<String>().trim().to_string();
224 let mut parts = text.splitn(2, ": ");
225 if let (Some(raw_key), Some(raw_value)) = (parts.next(), parts.next()) {
226 let key = raw_key.trim();
227 let value = raw_value.trim().to_string();
228
229 match key {
230 "Logged out of CS:GO" => stats.last_logout_csgo = Some(value),
231 "Launched CS:GO using Steam Client" | "Launched CSGO using Steam Client" => stats.last_launch_steam_client = Some(value),
232 "Started playing CS:GO" | "Started playing CSGO" => stats.start_play_csgo = Some(value),
233 "First Counter-Strike franchise game" => stats.first_played_cs_franchise = Some(value),
234 "Last known IP address" => stats.last_known_ip = Some(value),
235 "Earned a Service Medal" => stats.earned_service_medal = Some(value),
236 "CS:GO Profile Rank" | "CSGO Profile Rank" => stats.profile_rank = value.parse().ok(),
237 "Experience points earned towards next rank" => stats.xp_to_next_rank = value.parse().ok(),
238 "Anti-addiction online time" => stats.anti_addiction_online_time = Some(value),
239 _ => {}
240 }
241 }
242 }
243
244 for table in document.select(sel_kv_table()) {
246 let mut rows = table.select(sel_tr());
247 if let Some(header_row) = rows.next() {
248 let headers: Vec<String> = header_row.select(sel_th()).map(|h| h.text().collect::<String>().trim().to_lowercase()).collect();
249
250 if headers.contains(&"recorded activity".to_string()) && headers.contains(&"activity time".to_string()) {
251 for tr in rows {
252 let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
253 if cells.len() >= 2 {
254 let key = &cells[0];
255 let value = cells[1].clone();
256 match key.as_str() {
257 "Logged out of CS:GO" => stats.last_logout_csgo = Some(value),
258 "Launched CS:GO using Steam Client" => stats.last_launch_steam_client = Some(value),
259 "Started playing CS:GO" => stats.start_play_csgo = Some(value),
260 "First Counter-Strike franchise game" => stats.first_played_cs_franchise = Some(value),
261 _ => {}
262 }
263 }
264 }
265 }
266 }
267 }
268
269 Ok(stats)
270 }
271
272 #[steam_endpoint(POST, host = Api, path = "/ILoyaltyRewardsService/BatchedQueryRewardItems/v1", kind = Read)]
298 pub async fn fetch_batched_loyalty_reward_items(&self, app_ids: &[u32]) -> Result<Vec<steam_protos::messages::CLoyaltyRewardsBatchedQueryRewardItemsResponseResponse>, SteamUserError> {
299 use prost::Message;
300 use steam_protos::messages::{CLoyaltyRewardsBatchedQueryRewardItemsRequest, CLoyaltyRewardsBatchedQueryRewardItemsResponse, CLoyaltyRewardsQueryRewardItemsRequest};
301
302 if app_ids.is_empty() {
303 return Ok(Vec::new());
304 }
305
306 let request = CLoyaltyRewardsBatchedQueryRewardItemsRequest {
307 requests: app_ids
308 .iter()
309 .map(|&app_id| CLoyaltyRewardsQueryRewardItemsRequest {
310 appids: vec![app_id],
311 time_available: None,
312 community_item_classes: Vec::new(),
313 language: Some("english".to_string()),
314 count: Some(10),
315 cursor: None,
316 sort: Some(1),
317 sort_descending: Some(true),
318 reward_types: Vec::new(),
319 excluded_community_item_classes: Vec::new(),
320 definitionids: Vec::new(),
321 filters: Vec::new(),
322 filter_match_all_category_tags: Vec::new(),
323 filter_match_any_category_tags: Vec::new(),
324 contains_definitionids: Vec::new(),
325 include_direct_purchase_disabled: None,
326 excluded_content_descriptors: vec![3, 4],
327 excluded_appids: Vec::new(),
328 excluded_store_tagids: Vec::new(),
329 store_tagids: Vec::new(),
330 search_term: None,
331 })
332 .collect(),
333 };
334
335 let mut body = Vec::new();
336 request.encode(&mut body)?;
337
338 let params = [("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
339
340 let response = self.get_path("/ILoyaltyRewardsService/BatchedQueryRewardItems/v1").query(¶ms).send().await?;
341
342 if !response.status().is_success() {
343 return Err(SteamUserError::HttpStatus {
344 status: response.status().as_u16(),
345 url: response.url().to_string(),
346 });
347 }
348
349 let bytes = response.bytes().await?;
350 let response_proto = CLoyaltyRewardsBatchedQueryRewardItemsResponse::decode(bytes)?;
351
352 Ok(response_proto.responses)
353 }
354
355 #[steam_endpoint(GET, host = Community, path = "/my/games/", kind = Read)]
365 pub async fn get_owned_apps_detail(&self) -> Result<Vec<crate::types::apps::OwnedAppDetail>, SteamUserError> {
366 let html = self.my_profile_get("games/?tab=all").await?;
367
368 let start_marker = "var rgGames = ";
370 let end_marker = "var rgChangingGames = []";
371
372 let start = html.find(start_marker).ok_or_else(|| SteamUserError::MalformedResponse("rgGames not found".into()))?;
373
374 let rest = &html[start + start_marker.len()..];
375 let end = rest.find(end_marker).ok_or_else(|| SteamUserError::MalformedResponse("rgChangingGames not found".into()))?;
376
377 let json_str = rest[..end].trim().trim_end_matches(';').trim();
378
379 let apps: Vec<crate::types::apps::OwnedAppDetail> = serde_json::from_str(json_str).map_err(|e| SteamUserError::MalformedResponse(format!("Failed to parse rgGames: {}", e)))?;
380
381 Ok(apps)
382 }
383
384 #[steam_endpoint(GET, host = Store, path = "/dynamicstore/userdata/", kind = Read)]
394 pub async fn get_dynamic_store_user_data(&self) -> Result<crate::types::apps::DynamicStoreUserData, SteamUserError> {
395 let response = self.get_path("/dynamicstore/userdata/").send().await?;
396
397 if !response.status().is_success() {
398 return Err(SteamUserError::HttpStatus {
399 status: response.status().as_u16(),
400 url: response.url().to_string(),
401 });
402 }
403
404 let data: crate::types::apps::DynamicStoreUserData = response.json().await?;
405 Ok(data)
406 }
407
408 #[tracing::instrument(skip(self))]
417 pub async fn get_owned_apps_id(&self) -> Result<Vec<u32>, SteamUserError> {
418 let data = self.get_dynamic_store_user_data().await?;
419 Ok(data.owned_apps)
420 }
421
422 #[steam_endpoint(GET, host = Api, path = "/ISteamApps/UpToDateCheck/v1/", kind = Read)]
435 pub async fn get_steam_app_version_info(app_id: u32) -> Result<crate::types::apps::SteamAppVersionInfo, SteamUserError> {
436 let client = build_adhoc_client()?;
437 let response = client.get("https://api.steampowered.com/ISteamApps/UpToDateCheck/v1/").query(&[("format", "json"), ("appid", &app_id.to_string()), ("version", "0")]).send().await?;
438
439 if !response.status().is_success() {
440 return Err(SteamUserError::HttpStatus {
441 status: response.status().as_u16(),
442 url: response.url().to_string(),
443 });
444 }
445
446 let info: crate::types::apps::SteamAppVersionInfo = response.json().await?;
447 Ok(info)
448 }
449
450 #[steam_endpoint(GET, host = Store, path = "/search/suggest", kind = Read)]
463 pub async fn suggest_app_list(term: &str) -> Result<Vec<crate::types::apps::AppListItem>, SteamUserError> {
464 let client = build_adhoc_client()?;
465 let response = client.get("https://store.steampowered.com/search/suggest").query(&[("term", term), ("f", "games"), ("cc", "VN"), ("realm", "1"), ("l", "english"), ("use_store_query", "1")]).send().await?;
466
467 if !response.status().is_success() {
468 return Err(SteamUserError::HttpStatus {
469 status: response.status().as_u16(),
470 url: response.url().to_string(),
471 });
472 }
473
474 let html = response.text().await?;
475 let document = Html::parse_document(&html);
476
477 let mut items = Vec::new();
478 for element in document.select(sel_match_anchor()) {
479 let appid = element.value().attr("data-ds-appid").and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
480
481 let name = element.select(sel_match_name()).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
482
483 let img = element.select(sel_match_img()).next().and_then(|e| e.value().attr("src")).unwrap_or("").to_string();
484
485 let price = element.select(sel_match_price()).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
486
487 if appid > 0 {
488 items.push(crate::types::apps::AppListItem { appid, name, img, price });
489 }
490 }
491
492 Ok(items)
493 }
494
495 #[steam_endpoint(GET, host = Store, path = "/search/results/", kind = Read)]
508 pub async fn query_app_list(term: &str) -> Result<Vec<crate::types::apps::AppListItem>, SteamUserError> {
509 let client = build_adhoc_client()?;
510 let response = client.get("https://store.steampowered.com/search/results/").query(&[("query", ""), ("start", "0"), ("count", "50"), ("dynamic_data", ""), ("sort_by", "_ASC"), ("term", term), ("infinite", "1")]).send().await?;
511
512 if !response.status().is_success() {
513 return Err(SteamUserError::HttpStatus {
514 status: response.status().as_u16(),
515 url: response.url().to_string(),
516 });
517 }
518
519 #[derive(serde::Deserialize)]
520 struct SearchResponse {
521 results_html: Option<String>,
522 }
523
524 let data: SearchResponse = response.json().await?;
525 let results_html = data.results_html.unwrap_or_default();
526
527 let document = Html::parse_document(&results_html);
528
529 let mut items = Vec::new();
530 for element in document.select(sel_search_row()) {
531 let appid = element.value().attr("data-ds-appid").and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
532
533 let name = element.select(sel_search_name()).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
534
535 let img = element.select(sel_search_capsule_img()).next().and_then(|e| e.value().attr("src")).unwrap_or("").to_string();
536
537 let price = element.select(sel_search_price()).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
538
539 if appid > 0 {
540 items.push(crate::types::apps::AppListItem { appid, name, img, price });
541 }
542 }
543
544 Ok(items)
545 }
546
547 #[steam_endpoint(GET, host = Api, path = "/ISteamApps/GetAppList/v0002/", kind = Read)]
556 pub async fn get_app_list() -> Result<crate::types::apps::SimpleSteamAppList, SteamUserError> {
557 let client = build_adhoc_client()?;
558 let response = client.get("https://api.steampowered.com/ISteamApps/GetAppList/v0002/?format=json").send().await?;
559
560 if !response.status().is_success() {
561 return Err(SteamUserError::HttpStatus {
562 status: response.status().as_u16(),
563 url: response.url().to_string(),
564 });
565 }
566
567 let list: crate::types::apps::SimpleSteamAppList = response.json().await?;
568 Ok(list)
569 }
570
571 #[steam_endpoint(GET, host = Community, path = "/my/gcpd/730/", kind = Read)]
580 pub async fn fetch_matchmaking_stats(&self) -> Result<crate::types::apps::MatchmakingStats, SteamUserError> {
581 let html = self.my_profile_get("gcpd/730/?tab=matchmaking").await?;
582 Ok(parse_matchmaking_html(&html))
583 }
584}
585
586pub fn parse_matchmaking_html(html: &str) -> crate::types::apps::MatchmakingStats {
592 let document = Html::parse_document(html);
593
594 let mut matchmaking_cooldown = None;
595 let mut matchmaking_summary = Vec::new();
596 let mut matchmaking_per_map = Vec::new();
597 let mut last_played_modes = None;
598
599 for table in document.select(sel_kv_table()) {
600 let mut rows = table.select(sel_tr());
601 if let Some(header_row) = rows.next() {
602 let headers: Vec<String> = header_row.select(sel_th()).map(|h| h.text().collect::<String>().trim().to_lowercase()).collect();
603
604 if headers == ["competitive cooldown expiration", "competitive cooldown level", "acknowledged"] {
605 let mut list = Vec::new();
607 for tr in rows {
608 let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
609 if cells.len() >= 3 {
610 list.push(crate::types::apps::CooldownInfo {
611 competitive_cooldown_expiration: cells.first().map(|s| {
612 if s.eq_ignore_ascii_case("never") || s.is_empty() {
613 crate::types::apps::CooldownExpiration::Never
614 } else {
615 let clean = s.replace("GMT", "");
616 chrono::NaiveDateTime::parse_from_str(clean.trim(), "%Y-%m-%d %H:%M:%S").map(|dt| crate::types::apps::CooldownExpiration::At(dt.and_utc())).unwrap_or(crate::types::apps::CooldownExpiration::Never)
617 }
618 }),
619 competitive_cooldown_level: cells.get(1).and_then(|s| s.parse().ok()),
620 acknowledged: cells.get(2).is_some_and(|s| s.eq_ignore_ascii_case("yes")),
621 });
622 }
623 }
624 if !list.is_empty() {
625 matchmaking_cooldown = Some(list);
626 }
627 } else if headers == ["matchmaking mode", "map", "wins", "ties", "losses", "skill group", "last match", "region"] {
628 for tr in rows {
631 let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
632 if cells.len() >= 2 {
633 matchmaking_per_map.push(crate::types::apps::MatchmakingPerMap {
634 matchmaking_mode: cells.first().cloned(),
635 map: cells.get(1).cloned(),
636 wins: cells.get(2).and_then(|s| s.parse().ok()),
637 ties: cells.get(3).and_then(|s| s.parse().ok()),
638 losses: cells.get(4).and_then(|s| s.parse().ok()),
639 skill_group: cells.get(5).cloned(),
640 last_match: cells.get(6).cloned(),
641 region: cells.get(7).and_then(|s| s.parse().ok()),
642 });
643 }
644 }
645 } else if headers == ["matchmaking mode", "wins", "ties", "losses", "skill group", "last match", "region"] {
646 for tr in rows {
649 let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
650 if cells.len() >= 2 {
651 matchmaking_summary.push(crate::types::apps::MatchmakingSummary {
652 matchmaking_mode: cells.first().cloned(),
653 wins: cells.get(1).and_then(|s| s.parse().ok()),
654 ties: cells.get(2).and_then(|s| s.parse().ok()),
655 losses: cells.get(3).and_then(|s| s.parse().ok()),
656 skill_group: cells.get(4).cloned(),
657 last_match: cells.get(5).cloned(),
658 region: cells.get(6).and_then(|s| s.parse().ok()),
659 });
660 }
661 }
662 } else if headers == ["matchmaking mode", "last match"] {
663 let mut list = Vec::new();
665 for tr in rows {
666 let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
667 if cells.len() >= 2 {
668 list.push(crate::types::apps::LastPlayedMode { matchmaking_mode: cells.first().cloned(), last_match: cells.get(1).cloned() });
669 }
670 }
671 if !list.is_empty() {
672 last_played_modes = Some(list);
673 }
674 }
675 }
676 }
677
678 crate::types::apps::MatchmakingStats { matchmaking_cooldown, matchmaking_summary, matchmaking_per_map, last_played_modes }
679}
680
681impl SteamUser {
682 #[steam_endpoint(GET, host = Store, path = "/points/shop/c/events", kind = Read)]
692 pub async fn get_eligible_event_apps() -> Result<Vec<crate::types::apps::EligibleEventApp>, SteamUserError> {
693 let client = build_adhoc_client()?;
694 let response = client.get("https://store.steampowered.com/points/shop/c/events").send().await?;
695
696 if !response.status().is_success() {
697 return Err(SteamUserError::HttpStatus {
698 status: response.status().as_u16(),
699 url: response.url().to_string(),
700 });
701 }
702
703 let html = response.text().await?;
704 let document = Html::parse_document(&html);
705
706 let loyalty_str = document.select(sel_app_config()).next().and_then(|e| e.value().attr("data-loyaltystore")).ok_or_else(|| SteamUserError::MalformedResponse("data-loyaltystore not found".into()))?;
707
708 #[derive(serde::Deserialize)]
709 struct LoyaltyStore {
710 eligible_apps: Option<EligibleApps>,
711 }
712 #[derive(serde::Deserialize)]
713 struct EligibleApps {
714 apps: Vec<crate::types::apps::EligibleEventApp>,
715 }
716
717 let loyalty_obj: LoyaltyStore = serde_json::from_str(loyalty_str).map_err(|e| SteamUserError::MalformedResponse(format!("Failed to parse loyalty store: {}", e)))?;
718
719 let apps = loyalty_obj.eligible_apps.map(|ea| ea.apps.into_iter().filter(|a| a.event_app).collect()).unwrap_or_default();
720
721 Ok(apps)
722 }
723
724 #[steam_endpoint(POST, host = Api, path = "/ICommunityService/GetApps/v1", kind = Read)]
736 pub async fn get_community_apps(&self, app_ids: &[u32]) -> Result<steam_protos::messages::community::CCommunityGetAppsResponse, SteamUserError> {
737 use prost::Message;
738 use steam_protos::messages::community::CCommunityGetAppsRequest;
739
740 if app_ids.is_empty() {
741 return Ok(steam_protos::messages::community::CCommunityGetAppsResponse { apps: Vec::new() });
742 }
743
744 let request = CCommunityGetAppsRequest { appids: app_ids.to_vec(), language: Some(0) };
745
746 let mut body = Vec::new();
747 request.encode(&mut body)?;
748
749 let params = [("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
750
751 let response = self.get_path("/ICommunityService/GetApps/v1").query(¶ms).send().await?;
752
753 if !response.status().is_success() {
754 return Err(SteamUserError::HttpStatus {
755 status: response.status().as_u16(),
756 url: response.url().to_string(),
757 });
758 }
759
760 let bytes = response.bytes().await?;
761 let response_proto = steam_protos::messages::community::CCommunityGetAppsResponse::decode(bytes)?;
762
763 Ok(response_proto)
764 }
765
766 #[steam_endpoint(POST, host = Api, path = "/IStoreBrowseService/GetItems/v1", kind = Read)]
778 pub async fn get_steam_store_items(&self, app_ids: &[u32]) -> Result<steam_protos::messages::store::CStoreBrowseGetItemsResponse, SteamUserError> {
779 use prost::Message;
780 use steam_protos::messages::store::{
781 c_store_browse_get_items_request::{StoreBrowseContext, StoreBrowseItemDataRequest, StoreItemId},
782 CStoreBrowseGetItemsRequest,
783 };
784
785 if app_ids.is_empty() {
786 return Ok(steam_protos::messages::store::CStoreBrowseGetItemsResponse { store_items: Vec::new() });
787 }
788
789 let request = CStoreBrowseGetItemsRequest {
790 ids: app_ids.iter().map(|&appid| StoreItemId { appid: Some(appid), packageid: None, bundleid: None, tagid: None, creatorid: None, hubcategoryid: None }).collect(),
791 context: Some(StoreBrowseContext {
792 language: Some("english".to_string()),
793 elanguage: None,
794 country_code: Some("VN".to_string()),
795 steam_realm: Some(1),
796 }),
797 data_request: Some(StoreBrowseItemDataRequest {
798 include_assets: Some(true),
799 include_release: None,
800 include_platforms: None,
801 include_all_purchase_options: None,
802 include_screenshots: None,
803 include_trailers: None,
804 include_ratings: None,
805 include_tag_count: None,
806 include_reviews: None,
807 include_basic_info: None,
808 include_supported_languages: None,
809 include_full_description: None,
810 include_included_items: None,
811 included_item_data_request: None,
812 include_assets_without_overrides: None,
813 apply_user_filters: None,
814 include_links: None,
815 }),
816 };
817
818 let mut body = Vec::new();
819 request.encode(&mut body)?;
820
821 let params = [("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
822
823 let response = self.get_path("/IStoreBrowseService/GetItems/v1").query(¶ms).send().await?;
824
825 if !response.status().is_success() {
826 return Err(SteamUserError::HttpStatus {
827 status: response.status().as_u16(),
828 url: response.url().to_string(),
829 });
830 }
831
832 let bytes = response.bytes().await?;
833 let response_proto = steam_protos::messages::store::CStoreBrowseGetItemsResponse::decode(bytes)?;
834
835 Ok(response_proto)
836 }
837
838 #[steam_endpoint(POST, host = Api, path = "/ICheckoutService/GetFriendOwnershipForGifting/v1", kind = Read)]
854 pub async fn get_friend_ownership_for_gifting(&self, access_token: &str, package_id: u32) -> Result<crate::types::apps::FriendOwnershipResponse, SteamUserError> {
855 use prost::Message;
856
857 #[derive(Clone, PartialEq, ::prost::Message)]
858 pub struct OwnershipItemIdProto {
859 #[prost(uint32, optional, tag = "1")]
860 pub appid: Option<u32>,
861 #[prost(uint32, optional, tag = "2")]
862 pub packageid: Option<u32>,
863 #[prost(uint32, optional, tag = "3")]
864 pub bundleid: Option<u32>,
865 #[prost(uint32, optional, tag = "4")]
866 pub tagid: Option<u32>,
867 #[prost(uint32, optional, tag = "5")]
868 pub creatorid: Option<u32>,
869 #[prost(uint32, optional, tag = "6")]
870 pub hubcategoryid: Option<u32>,
871 }
872
873 #[derive(Clone, PartialEq, ::prost::Message)]
874 pub struct CCheckoutGetFriendOwnershipForGiftingRequest {
875 #[prost(message, repeated, tag = "1")]
876 pub item_ids: ::prost::alloc::vec::Vec<OwnershipItemIdProto>,
877 }
878
879 #[derive(Clone, PartialEq, ::prost::Message)]
880 pub struct FriendOwnershipProto {
881 #[prost(uint32, repeated, tag = "1")]
882 pub partial_owns_appids: ::prost::alloc::vec::Vec<u32>,
883 #[prost(uint32, repeated, tag = "2")]
884 pub partial_wishes_for: ::prost::alloc::vec::Vec<u32>,
885 #[prost(uint32, tag = "3")]
886 pub accountid: u32,
887 #[prost(bool, tag = "4")]
888 pub already_owns: bool,
889 #[prost(bool, tag = "5")]
890 pub wishes_for: bool,
891 }
892
893 #[derive(Clone, PartialEq, ::prost::Message)]
894 pub struct FriendOwnershipInfoProto {
895 #[prost(message, repeated, tag = "1")]
896 pub friend_ownership: ::prost::alloc::vec::Vec<FriendOwnershipProto>,
897 #[prost(message, optional, tag = "2")]
898 pub item_id: Option<OwnershipItemIdProto>,
899 }
900
901 #[derive(Clone, PartialEq, ::prost::Message)]
902 pub struct CCheckoutGetFriendOwnershipForGiftingResponse {
903 #[prost(message, repeated, tag = "1")]
904 pub ownership_info: ::prost::alloc::vec::Vec<FriendOwnershipInfoProto>,
905 }
906
907 let item_id = OwnershipItemIdProto { packageid: Some(package_id), appid: None, bundleid: None, tagid: None, creatorid: None, hubcategoryid: None };
908
909 let request = CCheckoutGetFriendOwnershipForGiftingRequest { item_ids: vec![item_id] };
910
911 let mut body = Vec::new();
912 request.encode(&mut body)?;
913
914 let params = [("access_token", access_token), ("spoof_steamid", ""), ("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
915
916 let response = self.get_path("/ICheckoutService/GetFriendOwnershipForGifting/v1").query(¶ms).send().await?;
917
918 if !response.status().is_success() {
919 return Err(SteamUserError::HttpStatus {
920 status: response.status().as_u16(),
921 url: response.url().to_string(),
922 });
923 }
924
925 let bytes = response.bytes().await?;
926 let response_proto = CCheckoutGetFriendOwnershipForGiftingResponse::decode(bytes)?;
927
928 let ownership_info = response_proto
929 .ownership_info
930 .into_iter()
931 .map(|info| crate::types::apps::FriendOwnershipInfo {
932 friend_ownership: info
933 .friend_ownership
934 .into_iter()
935 .map(|fo| crate::types::apps::FriendOwnership {
936 partial_owns_appids: fo.partial_owns_appids,
937 partial_wishes_for: fo.partial_wishes_for,
938 accountid: fo.accountid,
939 already_owns: fo.already_owns,
940 wishes_for: fo.wishes_for,
941 })
942 .collect(),
943 item_id: info.item_id.map(|id| crate::types::apps::OwnershipItemId {
944 appid: id.appid,
945 packageid: id.packageid,
946 bundleid: id.bundleid,
947 tagid: id.tagid,
948 creatorid: id.creatorid,
949 hubcategoryid: id.hubcategoryid,
950 }),
951 })
952 .collect();
953
954 Ok(crate::types::apps::FriendOwnershipResponse { ownership_info })
955 }
956}