Skip to main content

steam_user/services/
inventory.rs

1//! Inventory services.
2
3use steamid::SteamID;
4
5use crate::{
6    client::SteamUser,
7    endpoint::steam_endpoint,
8    error::SteamUserError,
9    types::{ActiveInventory, AppId, ContextId, EconItem, InventoryCursor, InventoryHistoryItem, InventoryHistoryResult, InventoryResponse, PriceOverview, TradePeople},
10};
11
12impl SteamUser {
13    /// Retrieves the contents of a specific user's inventory.
14    ///
15    /// This method handles pagination automatically and fetches all items for
16    /// the given application and context.
17    ///
18    /// # Arguments
19    ///
20    /// * `user_id` - The [`SteamID`] of the inventory owner.
21    /// * `appid` - The Steam App ID (e.g., 730 for CS:GO, 440 for TF2).
22    /// * `context_id` - The inventory context ID (usually 2).
23    ///
24    /// # Returns
25    ///
26    /// Returns a `Vec<EconItem>` containing all items in the specified
27    /// inventory.
28    #[steam_endpoint(GET, host = Community, path = "/inventory/{user_id}/{app_id}/{context_id}", kind = Read)]
29    pub async fn get_user_inventory_contents(&self, user_id: SteamID, appid: AppId, context_id: ContextId) -> Result<Vec<EconItem>, SteamUserError> {
30        let path = format!("/inventory/{}/{}/{}", user_id.steam_id64(), appid, context_id);
31
32        let mut items = Vec::new();
33        let mut start_assetid = None;
34
35        loop {
36            let mut request = self.get_path(&path).query(&[("count", "2000"), ("preserve_bbcode", "1"), ("raw_asset_properties", "1")]);
37
38            if let Some(start) = start_assetid.as_ref() {
39                request = request.query(&[("start_assetid", start)]);
40            }
41
42            let response: InventoryResponse = request.send().await?.error_for_status()?.json().await?;
43
44            tracing::info!(
45                path = %path,
46                success = response.success,
47                total_count = ?response.total_inventory_count,
48                assets = response.assets.len(),
49                descriptions = response.descriptions.len(),
50                "inventory fetch result",
51            );
52
53            if response.success != 1 {
54                tracing::warn!(success = response.success, "inventory fetch: Steam returned non-1 success, returning error");
55                return Err(SteamUserError::from_eresult(response.success));
56            }
57
58            // Build description lookup by classid_instanceid
59            let desc_map: std::collections::HashMap<String, std::sync::Arc<crate::types::InventoryDescription>> = response.descriptions.into_iter().map(|d| (format!("{}_{}", d.classid, d.instanceid), std::sync::Arc::new(d))).collect();
60
61            for asset in &response.assets {
62                let key = format!("{}_{}", asset.classid, asset.instanceid);
63                if let Some(desc) = desc_map.get(&key) {
64                    match EconItem::try_from_inventory_data(asset, desc.clone()) {
65                        Ok(mut item) => {
66                            if let Some(steam_id) = self.steam_id() {
67                                item.owner_steam_id = Some(steam_id);
68                            }
69                            items.push(item);
70                        }
71                        Err(e) => {
72                            tracing::warn!(assetid = %asset.assetid, classid = %asset.classid, error = %e, "skipping malformed inventory asset");
73                        }
74                    }
75                }
76            }
77
78            // Check for pagination
79            if response.more_items {
80                if let Some(last) = response.last_assetid {
81                    start_assetid = Some(last);
82                    continue;
83                }
84            }
85
86            break;
87        }
88
89        Ok(items)
90    }
91
92    /// Retrieves the inventory of the currently authenticated user.
93    ///
94    /// Convenience method that calls [`Self::get_user_inventory_contents`] for
95    /// the current session.
96    // delegates to `get_user_inventory_contents` — no #[steam_endpoint]
97    #[tracing::instrument(skip(self), fields(app_id = appid.get(), context_id = context_id.get()))]
98    pub async fn get_inventory(&self, appid: AppId, context_id: ContextId) -> Result<Vec<EconItem>, SteamUserError> {
99        let steam_id = self.steam_id().ok_or(SteamUserError::NotLoggedIn)?;
100        self.get_user_inventory_contents(steam_id, appid, context_id).await
101    }
102
103    /// Retrieves the authenticated user's inventory in the legacy JSON format
104    /// used by the trading UI.
105    ///
106    /// This format includes the `rgInventory` and `rgDescriptions` structures.
107    #[steam_endpoint(GET, host = Community, path = "/my/inventory/json/{app_id}/{context_id}", kind = Read)]
108    pub async fn get_inventory_trading(&self, appid: AppId, context_id: ContextId) -> Result<serde_json::Value, SteamUserError> {
109        let response: serde_json::Value = self.get_path(format!("/my/inventory/json/{}/{}", appid, context_id)).query(&[("trading", "1")]).send().await?.json().await?;
110
111        if response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
112            let mut enriched = response;
113            let steam_id = self.steam_id().map(|id| id.steam_id64().to_string()).unwrap_or_default();
114            let context_str = context_id.to_string();
115
116            if let Some(obj) = enriched.as_object_mut() {
117                if let Some(rg_inv) = obj.get_mut("rgInventory").and_then(|v| v.as_object_mut()) {
118                    for (_, item) in rg_inv {
119                        if let Some(item_obj) = item.as_object_mut() {
120                            item_obj.insert("steamId".to_string(), serde_json::json!(steam_id));
121                            item_obj.insert("contextId".to_string(), serde_json::json!(context_str));
122                        }
123                    }
124                }
125                if let Some(rg_desc) = obj.get_mut("rgDescriptions").and_then(|v| v.as_object_mut()) {
126                    for (_, desc) in rg_desc {
127                        if let Some(desc_obj) = desc.as_object_mut() {
128                            desc_obj.insert("steamId".to_string(), serde_json::json!(steam_id));
129                            desc_obj.insert("contextId".to_string(), serde_json::json!(context_str));
130                        }
131                    }
132                }
133            }
134            Ok(enriched)
135        } else {
136            Err(SteamUserError::MalformedResponse("Failed to fetch trading inventory".into()))
137        }
138    }
139
140    /// Retrieves current price statistics for a specific market item.
141    ///
142    /// # Arguments
143    ///
144    /// * `market_hash_name` - The unique name used on the Steam Community
145    ///   Market (e.g., "Clutch Case").
146    #[steam_endpoint(GET, host = Community, path = "/market/priceoverview/", kind = Read)]
147    pub async fn get_price_overview(&self, appid: AppId, market_hash_name: &str) -> Result<PriceOverview, SteamUserError> {
148        let appid_str = appid.to_string();
149        let response: PriceOverview = self
150            .get_path("/market/priceoverview/")
151            .query(&[
152                ("appid", &appid_str),
153                ("market_hash_name", &market_hash_name.to_string()),
154                ("currency", &"15".to_string()), // Default to VNĐ
155            ])
156            .send()
157            .await?
158            .json()
159            .await?;
160
161        Ok(response)
162    }
163
164    /// Normalize trade history description.
165    fn normalize_trade_description(description: &str) -> String {
166        const DESCRIPTION_LIST: &[&str] = &[
167            "You purchased an item on the Community Market.",
168            "You listed an item on the Community Market.",
169            "You canceled a listing on the Community Market. The item was returned to you.",
170            "Crafted",
171            "Expired",
172            "Earned a new rank and got a drop",
173            "Got an item drop",
174            "Random item drop",
175            "Purchased a gift",
176            "Earned by redeeming Steam Points",
177            "Earned by completing your Store Discovery Queue",
178            "Earned",
179            "Traded",
180            "Earned due to game play time",
181            "Listed on the Steam Community Market",
182            "Turned into Gems",
183            "Unpacked a booster pack",
184            "Purchased with Gems",
185            "Unpacked Gems from Sack",
186            "Earned by crafting",
187            "Used",
188            "Unsealed",
189            "Earned by sale purchases",
190            "Unlocked a container",
191            "Purchased from the store",
192            "You deleted",
193            "Found",
194            "Received from the Community Market",
195            "Exchanged one or more items for something different",
196            "Earned an item due to ownership of another game",
197            "Sticker applied",
198            "Sticker removed",
199            "Subscription/Seasonal Item Grant",
200            "Earned from unlocking an achievement",
201            "Moved to Storage Unit",
202        ];
203
204        if DESCRIPTION_LIST.contains(&description) {
205            return description.to_string();
206        }
207
208        if description.starts_with("You traded with ") {
209            return "You traded with".to_string();
210        }
211        if description.starts_with("Gift sent to and redeemed by ") {
212            return "Gift sent to and redeemed by".to_string();
213        }
214        if description.starts_with("Your trade with ") && description.ends_with(" was on hold, and the trade has now completed.") {
215            return "Your trade with friend was on hold, and the trade has now completed.".to_string();
216        }
217        if description.starts_with("You listed an item on the Community Market. The listing was placed on hold until") {
218            return "You listed an item on the Community Market. The listing was placed on hold until".to_string();
219        }
220        if description.starts_with("Earned in ") {
221            return "Earned in game".to_string();
222        }
223        if description.starts_with("Refunded a gift because the recipient,") && description.ends_with("declined") {
224            return "Refunded a gift because the recipient declined".to_string();
225        }
226        if description.starts_with("Your held trade with") && description.ends_with("was canceled. The items have been returned to you.") {
227            return "Your held trade with person was canceled. The items have been returned to you.".to_string();
228        }
229
230        description.to_string()
231    }
232
233    /// Retrieves a trading partner's inventory in the legacy trading JSON
234    /// format.
235    #[steam_endpoint(GET, host = Community, path = "/tradeoffer/new/partnerinventory/", kind = Read)]
236    pub async fn get_inventory_trading_partner(&self, appid: AppId, partner: SteamID, context_id: ContextId) -> Result<serde_json::Value, SteamUserError> {
237        let appid_str = appid.to_string();
238        let partner_str = partner.steam_id64().to_string();
239        let context_str = context_id.to_string();
240
241        let response: serde_json::Value = self.get_path("/tradeoffer/new/partnerinventory/").query(&[("partner", partner_str.as_str()), ("appid", appid_str.as_str()), ("contextid", context_str.as_str())]).header("Referer", "https://steamcommunity.com/tradeoffer/").send().await?.json().await?;
242
243        if response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
244            let mut enriched = response;
245            let partner_str = partner.steam_id64().to_string();
246            let context_str = context_id.to_string();
247
248            if let Some(obj) = enriched.as_object_mut() {
249                if let Some(rg_inv) = obj.get_mut("rgInventory").and_then(|v| v.as_object_mut()) {
250                    for (_, item) in rg_inv {
251                        if let Some(item_obj) = item.as_object_mut() {
252                            item_obj.insert("steamId".to_string(), serde_json::json!(partner_str));
253                            item_obj.insert("contextId".to_string(), serde_json::json!(context_str));
254                        }
255                    }
256                }
257                if let Some(rg_desc) = obj.get_mut("rgDescriptions").and_then(|v| v.as_object_mut()) {
258                    for (_, desc) in rg_desc {
259                        if let Some(desc_obj) = desc.as_object_mut() {
260                            desc_obj.insert("steamId".to_string(), serde_json::json!(partner_str));
261                            desc_obj.insert("contextId".to_string(), serde_json::json!(context_str));
262                        }
263                    }
264                }
265            }
266            Ok(enriched)
267        } else {
268            Err(SteamUserError::MalformedResponse("Failed to fetch trading partner inventory".into()))
269        }
270    }
271
272    /// Parses an inventory history date string like "24 Mar, 2023" + "3:49pm"
273    /// into a Unix timestamp (UTC seconds). Returns 0 on failure.
274    fn parse_inventory_history_date(date_text: &str, time_text: &str) -> u64 {
275        use chrono::NaiveDateTime;
276
277        let combined = format!("{} {}", date_text, time_text);
278        // Try format: "24 Mar, 2023 3:49pm" (English Steam format)
279        if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %l:%M%P") {
280            return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
281        }
282        // Try alternative: "24 Mar, 2023 3:49 pm"
283        if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %l:%M %P") {
284            return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
285        }
286        // Try 24h format: "24 Mar, 2023 15:49"
287        if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %H:%M") {
288            return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
289        }
290        tracing::warn!(combined = %combined, "failed to parse inventory history date");
291        0
292    }
293
294    /// Retrieves the inventory history for the authenticated user.
295    ///
296    /// This includes detailed records of items added or removed via trades,
297    /// drops, market actions, etc.
298    ///
299    /// # Arguments
300    ///
301    /// * `cursor` - Optional [`InventoryCursor`] for paginated results. Use
302    ///   `None` for the first page.
303    #[steam_endpoint(GET, host = Community, path = "/my/inventoryhistory/", kind = Read)]
304    pub async fn get_inventory_history(&self, cursor: Option<InventoryCursor>) -> Result<InventoryHistoryResult, SteamUserError> {
305        let mut query = vec![("ajax", "1"), ("l", "english")];
306
307        let cursor = cursor.unwrap_or_default();
308        let cursor_s = cursor.s.to_string();
309        let cursor_time_frac = cursor.time_frac.to_string();
310        let cursor_time = cursor.time.to_string();
311
312        query.push(("cursor[s]", cursor_s.as_str()));
313        query.push(("cursor[time_frac]", cursor_time_frac.as_str()));
314        query.push(("cursor[time]", cursor_time.as_str()));
315
316        let response: serde_json::Value = self.get_path("/my/inventoryhistory/").query(&query).send().await?.json().await?;
317
318        if !response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
319            return Err(SteamUserError::MalformedResponse("Failed to fetch inventory history".into()));
320        }
321
322        let html = response.get("html").and_then(|v| v.as_str()).unwrap_or("").replace(['\t', '\n', '\r'], "");
323
324        let descriptions = response.get("descriptions").cloned();
325
326        // Extract the pagination cursor up front so the heavy HTML parse can
327        // run on the blocking pool without needing `response` afterwards.
328        let next_cursor = response.get("cursor").and_then(|v| {
329            Some(InventoryCursor {
330                time: v.get("time")?.as_u64()?,
331                time_frac: u32::try_from(v.get("time_frac")?.as_u64()?).ok()?,
332                s: u32::try_from(v.get("s")?.as_u64()?).ok()?,
333            })
334        });
335
336        let steamid = self.steam_id();
337
338        // Inventory history pages can be several megabytes; the parse + CSS
339        // selection is CPU-bound and would block the runtime if run inline.
340        let trade_history = tokio::task::spawn_blocking(move || parse_inventory_history_rows(&html, descriptions.as_ref(), steamid)).await.map_err(|e| SteamUserError::Other(format!("inventory-history parse task failed: {e}")))??;
341
342        Ok(InventoryHistoryResult { cursor: next_cursor, trade_history })
343    }
344
345    /// Fetches the entire inventory history by automatically iterating through
346    /// all pages.
347    ///
348    /// WARNING: This can make many requests for accounts with extensive
349    /// history.
350    // paginates `get_inventory_history` — no #[steam_endpoint]
351    #[tracing::instrument(skip(self))]
352    pub async fn get_full_inventory_history(&self) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
353        let mut trade_history = Vec::new();
354        let mut cursor = None;
355
356        loop {
357            let result = self.get_inventory_history(cursor).await?;
358            trade_history.extend(result.trade_history);
359
360            if result.cursor.is_none() {
361                break;
362            }
363            cursor = result.cursor;
364        }
365
366        Ok(trade_history)
367    }
368
369    /// Retrieves a list of apps that have items in the authenticated user's
370    /// inventory.
371    ///
372    /// This method parses the user's inventory page to extract information
373    /// about which games have items and how many items are in each
374    /// inventory.
375    ///
376    /// # Returns
377    ///
378    /// Returns a `Vec<ActiveInventory>` containing information about each app
379    /// with inventory items, including the app ID, game icon, game name,
380    /// and item count.
381    #[steam_endpoint(GET, host = Community, path = "/my/inventory", kind = Read)]
382    pub async fn get_active_inventories(&self) -> Result<Vec<ActiveInventory>, SteamUserError> {
383        let html = self.get_with_manual_redirects("https://steamcommunity.com/my/inventory").await?;
384
385        // Inventory HTML can be large; parse and traverse on the blocking pool.
386        tokio::task::spawn_blocking(move || parse_active_inventories(&html)).await.map_err(|e| SteamUserError::Other(format!("active-inventories parse task failed: {e}")))?
387    }
388}
389
390/// Parses the rows of an inventory-history HTML response into typed
391/// [`InventoryHistoryItem`]s. Extracted as a pure synchronous function so the
392/// caller can run it on `tokio::task::spawn_blocking`.
393fn parse_inventory_history_rows(html: &str, descriptions: Option<&serde_json::Value>, steamid: Option<SteamID>) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
394    use scraper::{Html, Selector};
395    let document = Html::parse_document(html);
396    let row_selector = Selector::parse(".tradehistoryrow").map_err(|e| SteamUserError::Other(e.to_string()))?;
397    let date_selector = Selector::parse(".tradehistory_date").map_err(|e| SteamUserError::Other(e.to_string()))?;
398    let timestamp_selector = Selector::parse(".tradehistory_timestamp").map_err(|e| SteamUserError::Other(e.to_string()))?;
399    let event_desc_selector = Selector::parse(".tradehistory_event_description").map_err(|e| SteamUserError::Other(e.to_string()))?;
400    let link_selector = Selector::parse("a[href]").map_err(|e| SteamUserError::Other(e.to_string()))?;
401    let plusminus_selector = Selector::parse(".tradehistory_items_plusminus").map_err(|e| SteamUserError::Other(e.to_string()))?;
402    let items_group_selector = Selector::parse(".tradehistory_items").map_err(|e| SteamUserError::Other(e.to_string()))?;
403    let item_selector = Selector::parse(".tradehistory_items_group > .history_item").map_err(|e| SteamUserError::Other(e.to_string()))?;
404
405    let mut trade_history = Vec::new();
406
407    for row in document.select(&row_selector) {
408        let date_el = row.select(&date_selector).next();
409        let timestamp_el = row.select(&timestamp_selector).next();
410
411        let timestamp_text = timestamp_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
412
413        let date_text = date_el
414            .map(|e| {
415                let mut text = e.text().collect::<String>();
416                if !timestamp_text.is_empty() {
417                    text = text.replace(&timestamp_text, "");
418                }
419                text.trim().to_string()
420            })
421            .unwrap_or_default();
422
423        let event_desc_el = row.select(&event_desc_selector).next();
424        let raw_description = event_desc_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
425
426        let description = SteamUser::normalize_trade_description(&raw_description);
427
428        let mut trade_people = None;
429        if let Some(event_el) = event_desc_el {
430            if let Some(link_el) = event_el.select(&link_selector).next() {
431                let href = link_el.value().attr("href").unwrap_or("");
432                if href.contains("steamcommunity.com/profiles/") || href.contains("steamcommunity.com/id/") {
433                    trade_people = Some(TradePeople { name: link_el.text().collect::<String>().trim().to_string(), url: href.to_string() });
434                }
435            }
436        }
437
438        let plusminus = row.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
439
440        let mut trade_history_items = Vec::new();
441
442        for items_el in row.select(&items_group_selector) {
443            let item_plusminus = items_el.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
444
445            for item_el in items_el.select(&item_selector) {
446                let text = item_el.text().collect::<String>().trim().to_string();
447                if text == "You did not receive any items in this trade." {
448                    continue;
449                }
450
451                let appid = item_el.value().attr("data-appid").unwrap_or("");
452                let classid = item_el.value().attr("data-classid").unwrap_or("");
453                let instanceid = item_el.value().attr("data-instanceid").unwrap_or("0");
454                let context_id = item_el.value().attr("data-contextid").unwrap_or("");
455
456                let mut item_obj = serde_json::json!({
457                    "appid": appid,
458                    "classid": classid,
459                    "instanceid": instanceid,
460                    "contextid": context_id,
461                    "steamid": steamid,
462                    "plusminus": item_plusminus,
463                });
464
465                if let Some(descs) = descriptions {
466                    if let Some(asset_desc) = descs.get(appid).and_then(|a| a.get(format!("{}_{}", classid, instanceid))) {
467                        if let Some(obj) = item_obj.as_object_mut() {
468                            if let Some(desc_obj) = asset_desc.as_object() {
469                                for (k, v) in desc_obj {
470                                    obj.insert(k.clone(), v.clone());
471                                }
472                            }
473                        }
474                    }
475                }
476                trade_history_items.push(item_obj);
477            }
478        }
479
480        if !trade_history_items.is_empty() {
481            let timestamp_str = format!("{} {}", date_text, timestamp_text);
482            let timestamp = SteamUser::parse_inventory_history_date(&date_text, &timestamp_text);
483
484            let items_key: String = trade_history_items
485                .iter()
486                .map(|item| {
487                    let classid = item.get("classid").and_then(|v| v.as_str()).unwrap_or("0");
488                    let instanceid = item.get("instanceid").and_then(|v| v.as_str()).unwrap_or("0");
489                    format!("{}_{}", classid, instanceid)
490                })
491                .collect::<Vec<_>>()
492                .join("|");
493            let id_raw = format!("{}_{}_{}_{}", timestamp, description, plusminus, items_key,);
494            let id: String = id_raw.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '+' | '_' | '|')).collect::<String>().to_lowercase();
495
496            trade_history.push(InventoryHistoryItem { id, timestamp, timestamp_str, description, plusminus, trade_history_items, steamid, trade_people });
497        }
498    }
499
500    Ok(trade_history)
501}
502
503/// Parses the `/my/inventory` page and returns the per-app inventory summary.
504///
505/// Extracted as a pure synchronous function so the caller can dispatch it onto
506/// `tokio::task::spawn_blocking`.
507fn parse_active_inventories(html: &str) -> Result<Vec<ActiveInventory>, SteamUserError> {
508    use scraper::{Html, Selector};
509    let document = Html::parse_document(html);
510
511    let games_list_selector = Selector::parse("#games_list_public").map_err(|e| SteamUserError::Other(e.to_string()))?;
512
513    let Some(games_list) = document.select(&games_list_selector).next() else {
514        // No games list found - either no inventories or private profile
515        return Ok(Vec::new());
516    };
517
518    let tab_selector = Selector::parse(".games_list_tabs > .games_list_tab").map_err(|e| SteamUserError::Other(e.to_string()))?;
519    let icon_selector = Selector::parse(".item_desc_game_icon img").map_err(|e| SteamUserError::Other(e.to_string()))?;
520    let name_selector = Selector::parse(".games_list_tab_name").map_err(|e| SteamUserError::Other(e.to_string()))?;
521    let count_selector = Selector::parse(".games_list_tab_number").map_err(|e| SteamUserError::Other(e.to_string()))?;
522
523    let mut inventories = Vec::new();
524
525    for tab in games_list.select(&tab_selector) {
526        let game_icon = tab.select(&icon_selector).next().and_then(|el| el.value().attr("src")).map(|s| s.to_string());
527
528        let game_name = tab.select(&name_selector).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
529
530        let count = tab
531            .select(&count_selector)
532            .next()
533            .map(|el| {
534                let text = el.text().collect::<String>();
535                let text = text.trim();
536                text.trim_start_matches('(').trim_end_matches(')').parse::<u32>().unwrap_or(0)
537            })
538            .unwrap_or(0);
539
540        let app_id = tab.value().attr("id").and_then(|id| id.strip_prefix("inventory_link_")).and_then(|id| id.parse::<u32>().ok()).unwrap_or(0);
541
542        if app_id > 0 {
543            inventories.push(ActiveInventory { app_id, game_icon, game_name, count });
544        }
545    }
546
547    Ok(inventories)
548}