Skip to main content

egs_api/
lib.rs

1#![deny(missing_docs)]
2#![cfg_attr(test, deny(warnings))]
3
4//! # Epic Games Store API
5//!
6//! Async Rust client for the Epic Games Store API. Provides authentication,
7//! asset management, download manifest parsing (binary + JSON), and
8//! [Fab](https://www.fab.com/) marketplace integration.
9//!
10//! # Quick Start
11//!
12//! ```rust,no_run
13//! use egs_api::EpicGames;
14//!
15//! #[tokio::main]
16//! async fn main() {
17//!     let mut egs = EpicGames::new();
18//!
19//!     // Authenticate with an authorization code
20//!     let code = "your_authorization_code".to_string();
21//!     if egs.auth_code(None, Some(code)).await {
22//!         println!("Logged in as {}", egs.user_details().display_name.unwrap_or_default());
23//!     }
24//!
25//!     // List all owned assets
26//!     let assets = egs.list_assets(None, None).await;
27//!     println!("You own {} assets", assets.len());
28//! }
29//! ```
30//!
31//! # Authentication
32//!
33//! Epic uses OAuth2 with a public launcher client ID. The flow is:
34//!
35//! 1. Open the [authorization URL] in a browser — the user logs in and gets
36//!    redirected to a JSON page with an `authorizationCode`.
37//! 2. Pass that code to [`EpicGames::auth_code`].
38//! 3. Persist the session with [`EpicGames::user_details`] (implements
39//!    `Serialize` / `Deserialize`).
40//! 4. Restore it later with [`EpicGames::set_user_details`] +
41//!    [`EpicGames::login`], which uses the refresh token.
42//!
43//! [authorization URL]: https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Fid%2Fapi%2Fredirect%3FclientId%3D34a02cf8f4414e29b15921876da36f9a%26responseType%3Dcode
44//!
45//! # Features
46//!
47//! - **Assets** — List owned assets, fetch catalog metadata (with DLC trees),
48//!   retrieve asset manifests with CDN download URLs.
49//! - **Download Manifests** — Parse Epic's binary and JSON manifest formats.
50//!   Exposes file lists, chunk hashes, and custom fields for download
51//!   reconstruction.
52//! - **Fab Marketplace** — List Fab library items, fetch signed asset manifests,
53//!   and download manifests from distribution points.
54//! - **Account** — Details, bulk ID lookup, friends list.
55//! - **Entitlements** — Games, DLC, subscriptions.
56//! - **Library** — Paginated listing with optional metadata.
57//! - **Tokens** — Game exchange tokens and per-asset ownership tokens (JWT).
58//!
59//! # Architecture
60//!
61//! [`EpicGames`] is the public facade. It wraps an internal `EpicAPI` struct
62//! that holds the `reqwest::Client` (with cookie store) and session state.
63//! Most public methods return `Option<T>` or `Vec<T>`, swallowing transport
64//! errors for convenience. Fab methods return `Result<T, EpicAPIError>` to
65//! expose timeout/error distinctions.
66//!
67//! # Examples
68//!
69//! The crate ships with examples covering every endpoint. See the
70//! [`examples/`](https://github.com/AchetaGames/egs-api-rs/tree/master/examples)
71//! directory or run:
72//!
73//! ```bash
74//! cargo run --example auth                # Interactive login + token persistence
75//! cargo run --example account             # Account details, ID lookup, friends, external auths, SSO
76//! cargo run --example entitlements        # List all entitlements
77//! cargo run --example library             # Paginated library listing
78//! cargo run --example assets              # Full pipeline: list → info → manifest → download
79//! cargo run --example game_token          # Exchange code + ownership token
80//! cargo run --example fab                 # Fab library → asset manifest → download manifest
81//! cargo run --example catalog             # Catalog items, offers, bulk lookup
82//! cargo run --example commerce            # Currencies, prices, billing, quick purchase
83//! cargo run --example status              # Service status (lightswitch API)
84//! cargo run --example presence            # Update online presence
85//! cargo run --example client_credentials  # App-level auth + library state tokens
86//! ```
87
88use crate::api::types::account::{AccountData, AccountInfo, ExternalAuth, UserData};
89use crate::api::types::epic_asset::EpicAsset;
90use crate::api::types::fab_asset_manifest::DownloadInfo;
91use crate::api::types::friends::Friend;
92use crate::api::{EpicAPI};
93
94use api::types::asset_info::{AssetInfo, GameToken};
95use api::types::asset_manifest::AssetManifest;
96use api::types::billing_account::BillingAccount;
97use api::types::catalog_item::CatalogItemPage;
98use api::types::catalog_offer::CatalogOfferPage;
99use api::types::currency::CurrencyPage;
100use api::types::download_manifest::DownloadManifest;
101use api::types::entitlement::Entitlement;
102use api::types::library::Library;
103use api::types::presence::PresenceUpdate;
104use api::types::price::PriceResponse;
105use api::types::quick_purchase::QuickPurchaseResponse;
106use api::types::service_status::ServiceStatus;
107use log::{error, info, warn};
108use crate::api::error::EpicAPIError;
109
110/// Module for authenticated API communication
111pub mod api;
112
113/// Client for the Epic Games Store API.
114///
115/// This is the main entry point for the library. Create an instance with
116/// [`EpicGames::new`], authenticate with [`EpicGames::auth_code`] or
117/// [`EpicGames::login`], then call API methods.
118///
119/// Most methods return `Option<T>` or `Vec<T>`, returning `None` / empty on
120/// errors. Fab methods return `Result<T, EpicAPIError>` for richer error
121/// handling (e.g., distinguishing timeouts from other failures).
122///
123/// Session state is stored in [`UserData`], which implements
124/// `Serialize` / `Deserialize` for persistence across runs.
125#[derive(Debug, Clone)]
126pub struct EpicGames {
127    egs: EpicAPI,
128}
129
130impl Default for EpicGames {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl EpicGames {
137    /// Creates a new [`EpicGames`] client.
138    pub fn new() -> Self {
139        EpicGames {
140            egs: EpicAPI::new(),
141        }
142    }
143
144    /// Check whether the user is logged in.
145    ///
146    /// Returns `true` if the access token exists and has more than 600 seconds
147    /// remaining before expiry.
148    pub fn is_logged_in(&self) -> bool {
149        if let Some(exp) = self.egs.user_data.expires_at {
150            let now = chrono::offset::Utc::now();
151            let td = exp - now;
152            if td.num_seconds() > 600 {
153                return true;
154            }
155        }
156        false
157    }
158
159    /// Returns a clone of the current session state.
160    ///
161    /// The returned [`UserData`] implements `Serialize` / `Deserialize`,
162    /// so you can persist it to disk and restore it later with
163    /// [`set_user_details`](Self::set_user_details).
164    pub fn user_details(&self) -> UserData {
165        self.egs.user_data.clone()
166    }
167
168    /// Restore session state from a previously saved [`UserData`].
169    ///
170    /// Only merges `Some` fields — existing values are preserved for any
171    /// field that is `None` in the input. Call [`login`](Self::login)
172    /// afterward to refresh the access token.
173    pub fn set_user_details(&mut self, user_details: UserData) {
174        self.egs.user_data.update(user_details);
175    }
176
177    /// Like [`auth_code`](Self::auth_code), but returns a `Result` instead of swallowing errors.
178    pub async fn try_auth_code(
179        &mut self,
180        exchange_token: Option<String>,
181        authorization_code: Option<String>,
182    ) -> Result<bool, EpicAPIError> {
183        self.egs
184            .start_session(exchange_token, authorization_code)
185            .await
186    }
187
188    /// Authenticate with an authorization code or exchange token.
189    ///
190    /// Returns `true` on success, `false` on failure. Returns `None` on API errors.
191    pub async fn auth_code(
192        &mut self,
193        exchange_token: Option<String>,
194        authorization_code: Option<String>,
195    ) -> bool {
196        self.try_auth_code(exchange_token, authorization_code)
197            .await
198            .unwrap_or(false)
199    }
200
201    /// Invalidate the current session and log out.
202    pub async fn logout(&mut self) -> bool {
203        self.egs.invalidate_sesion().await
204    }
205
206    /// Like [`login`](Self::login), but returns a `Result` instead of swallowing errors.
207    pub async fn try_login(&mut self) -> Result<bool, EpicAPIError> {
208        if let Some(exp) = self.egs.user_data.expires_at {
209            let now = chrono::offset::Utc::now();
210            let td = exp - now;
211            if td.num_seconds() > 600 {
212                info!("Trying to re-use existing login session... ");
213                let resumed = self.egs.resume_session().await.map_err(|e| {
214                    warn!("{}", e);
215                    e
216                })?;
217                if resumed {
218                    info!("Logged in");
219                    return Ok(true);
220                }
221                return Ok(false);
222            }
223        }
224        info!("Logging in...");
225        if let Some(exp) = self.egs.user_data.refresh_expires_at {
226            let now = chrono::offset::Utc::now();
227            let td = exp - now;
228            if td.num_seconds() > 600 {
229                let started = self.egs.start_session(None, None).await.map_err(|e| {
230                    error!("{}", e);
231                    e
232                })?;
233                if started {
234                    info!("Logged in");
235                    return Ok(true);
236                }
237                return Ok(false);
238            }
239        }
240        Ok(false)
241    }
242
243    /// Resume session using the saved refresh token.
244    ///
245    /// Returns `true` on success, `false` if the refresh token has expired or is invalid.
246    /// Unlike [`try_login`](Self::try_login), this method falls through to
247    /// refresh-token login if session resume fails.
248    pub async fn login(&mut self) -> bool {
249        if let Some(exp) = self.egs.user_data.expires_at {
250            let now = chrono::offset::Utc::now();
251            let td = exp - now;
252            if td.num_seconds() > 600 {
253                info!("Trying to re-use existing login session... ");
254                match self.egs.resume_session().await {
255                    Ok(b) => {
256                        if b {
257                            info!("Logged in");
258                            return true;
259                        }
260                        return false;
261                    }
262                    Err(e) => {
263                        warn!("{}", e)
264                    }
265                };
266            }
267        }
268        info!("Logging in...");
269        if let Some(exp) = self.egs.user_data.refresh_expires_at {
270            let now = chrono::offset::Utc::now();
271            let td = exp - now;
272            if td.num_seconds() > 600 {
273                match self.egs.start_session(None, None).await {
274                    Ok(b) => {
275                        if b {
276                            info!("Logged in");
277                            return true;
278                        }
279                        return false;
280                    }
281                    Err(e) => {
282                        error!("{}", e)
283                    }
284                }
285            }
286        }
287        false
288    }
289
290    /// Like [`list_assets`](Self::list_assets), but returns a `Result` instead of swallowing errors.
291    pub async fn try_list_assets(
292        &mut self,
293        platform: Option<String>,
294        label: Option<String>,
295    ) -> Result<Vec<EpicAsset>, EpicAPIError> {
296        self.egs.assets(platform, label).await
297    }
298
299    /// List all owned assets.
300    ///
301    /// Defaults to platform="Windows" and label="Live" if not specified.
302    /// Returns empty `Vec` on API errors.
303    pub async fn list_assets(
304        &mut self,
305        platform: Option<String>,
306        label: Option<String>,
307    ) -> Vec<EpicAsset> {
308        self.try_list_assets(platform, label)
309            .await
310            .unwrap_or_else(|_| Vec::new())
311    }
312
313    /// Like [`asset_manifest`](Self::asset_manifest), but returns a `Result` instead of swallowing errors.
314    pub async fn try_asset_manifest(
315        &mut self,
316        platform: Option<String>,
317        label: Option<String>,
318        namespace: Option<String>,
319        item_id: Option<String>,
320        app: Option<String>,
321    ) -> Result<AssetManifest, EpicAPIError> {
322        self.egs
323            .asset_manifest(platform, label, namespace, item_id, app)
324            .await
325    }
326
327    /// Fetch asset manifest with CDN download URLs.
328    ///
329    /// Defaults to platform="Windows" and label="Live" if not specified.
330    /// Returns `None` on API errors.
331    pub async fn asset_manifest(
332        &mut self,
333        platform: Option<String>,
334        label: Option<String>,
335        namespace: Option<String>,
336        item_id: Option<String>,
337        app: Option<String>,
338    ) -> Option<AssetManifest> {
339        self.try_asset_manifest(platform, label, namespace, item_id, app)
340            .await
341            .ok()
342    }
343
344    /// Fetch Fab asset manifest with signed distribution points.
345    ///
346    /// Returns `Result` to expose timeout errors (403 → `EpicAPIError::FabTimeout`).
347    pub async fn fab_asset_manifest(
348        &self,
349        artifact_id: &str,
350        namespace: &str,
351        asset_id: &str,
352        platform: Option<&str>,
353    ) -> Result<Vec<DownloadInfo>, EpicAPIError> {
354        match self
355            .egs
356            .fab_asset_manifest(artifact_id, namespace, asset_id, platform)
357            .await
358        {
359            Ok(a) => Ok(a),
360            Err(e) => Err(e),
361        }
362    }
363
364    /// Like [`asset_info`](Self::asset_info), but returns a `Result` instead of swallowing errors.
365    pub async fn try_asset_info(
366        &mut self,
367        asset: &EpicAsset,
368    ) -> Result<Option<AssetInfo>, EpicAPIError> {
369        let mut info = self.egs.asset_info(asset).await?;
370        Ok(info.remove(asset.catalog_item_id.as_str()))
371    }
372
373    /// Fetch catalog metadata for an asset (includes DLC tree).
374    ///
375    /// Returns `None` on API errors.
376    pub async fn asset_info(&mut self, asset: &EpicAsset) -> Option<AssetInfo> {
377        self.try_asset_info(asset).await.ok().flatten()
378    }
379
380    /// Like [`account_details`](Self::account_details), but returns a `Result` instead of swallowing errors.
381    pub async fn try_account_details(&mut self) -> Result<AccountData, EpicAPIError> {
382        self.egs.account_details().await
383    }
384
385    /// Fetch account details (email, display name, country, 2FA status).
386    ///
387    /// Returns `None` on API errors.
388    pub async fn account_details(&mut self) -> Option<AccountData> {
389        self.try_account_details().await.ok()
390    }
391
392    /// Like [`account_ids_details`](Self::account_ids_details), but returns a `Result` instead of swallowing errors.
393    pub async fn try_account_ids_details(
394        &mut self,
395        ids: Vec<String>,
396    ) -> Result<Vec<AccountInfo>, EpicAPIError> {
397        self.egs.account_ids_details(ids).await
398    }
399
400    /// Bulk lookup of account IDs to display names.
401    ///
402    /// Returns `None` on API errors.
403    pub async fn account_ids_details(&mut self, ids: Vec<String>) -> Option<Vec<AccountInfo>> {
404        self.try_account_ids_details(ids).await.ok()
405    }
406
407    /// Like [`account_friends`](Self::account_friends), but returns a `Result` instead of swallowing errors.
408    pub async fn try_account_friends(
409        &mut self,
410        include_pending: bool,
411    ) -> Result<Vec<Friend>, EpicAPIError> {
412        self.egs.account_friends(include_pending).await
413    }
414
415    /// Fetch friends list (including pending requests if `include_pending` is true).
416    ///
417    /// Returns `None` on API errors.
418    pub async fn account_friends(&mut self, include_pending: bool) -> Option<Vec<Friend>> {
419        self.try_account_friends(include_pending).await.ok()
420    }
421
422    /// Like [`game_token`](Self::game_token), but returns a `Result` instead of swallowing errors.
423    pub async fn try_game_token(&mut self) -> Result<GameToken, EpicAPIError> {
424        self.egs.game_token().await
425    }
426
427    /// Fetch a short-lived exchange code for game launches.
428    ///
429    /// Returns `None` on API errors.
430    pub async fn game_token(&mut self) -> Option<GameToken> {
431        self.try_game_token().await.ok()
432    }
433
434    /// Like [`ownership_token`](Self::ownership_token), but returns a `Result` instead of swallowing errors.
435    pub async fn try_ownership_token(&mut self, asset: &EpicAsset) -> Result<String, EpicAPIError> {
436        self.egs.ownership_token(asset).await.map(|a| a.token)
437    }
438
439    /// Fetch a JWT proving ownership of an asset.
440    ///
441    /// Returns `None` on API errors.
442    pub async fn ownership_token(&mut self, asset: &EpicAsset) -> Option<String> {
443        self.try_ownership_token(asset).await.ok()
444    }
445
446    /// Like [`user_entitlements`](Self::user_entitlements), but returns a `Result` instead of swallowing errors.
447    pub async fn try_user_entitlements(&mut self) -> Result<Vec<Entitlement>, EpicAPIError> {
448        self.egs.user_entitlements().await
449    }
450
451    /// Fetch all user entitlements (games, DLC, subscriptions).
452    ///
453    /// Returns empty `Vec` on API errors.
454    pub async fn user_entitlements(&mut self) -> Vec<Entitlement> {
455        self.try_user_entitlements().await.unwrap_or_else(|_| Vec::new())
456    }
457
458    /// Like [`library_items`](Self::library_items), but returns a `Result` instead of swallowing errors.
459    pub async fn try_library_items(&mut self, include_metadata: bool) -> Result<Library, EpicAPIError> {
460        self.egs.library_items(include_metadata).await
461    }
462
463    /// Fetch the user library with optional metadata.
464    ///
465    /// Paginates internally and returns all records at once. Returns `None` on API errors.
466    pub async fn library_items(&mut self, include_metadata: bool) -> Option<Library> {
467        self.try_library_items(include_metadata).await.ok()
468    }
469
470    /// Like [`fab_library_items`](Self::fab_library_items), but returns a `Result` instead of swallowing errors.
471    pub async fn try_fab_library_items(
472        &mut self,
473        account_id: String,
474    ) -> Result<api::types::fab_library::FabLibrary, EpicAPIError> {
475        self.egs.fab_library_items(account_id).await
476    }
477
478    /// Fetch the user Fab library.
479    ///
480    /// Paginates internally and returns all records at once. Returns `None` on API errors.
481    pub async fn fab_library_items(
482        &mut self,
483        account_id: String,
484    ) -> Option<api::types::fab_library::FabLibrary> {
485        self.try_fab_library_items(account_id).await.ok()
486    }
487
488    /// Parse download manifests from all CDN mirrors.
489    ///
490    /// Fetches from all mirrors, parses binary/JSON format, and populates custom fields
491    /// (BaseUrl, CatalogItemId, etc.). Returns empty `Vec` on API errors.
492    pub async fn asset_download_manifests(&self, manifest: AssetManifest) -> Vec<DownloadManifest> {
493        self.egs.asset_download_manifests(manifest).await
494    }
495
496    /// Parse a Fab download manifest from a specific distribution point.
497    ///
498    /// Checks signature expiration before fetching. Returns `Result` to expose timeout errors.
499    pub async fn fab_download_manifest(
500        &self,
501        download_info: DownloadInfo,
502        distribution_point_url: &str,
503    ) -> Result<DownloadManifest, EpicAPIError> {
504        self.egs
505            .fab_download_manifest(download_info, distribution_point_url)
506            .await
507    }
508
509    /// Like [`auth_client_credentials`](Self::auth_client_credentials), but returns a `Result` instead of swallowing errors.
510    pub async fn try_auth_client_credentials(&mut self) -> Result<bool, EpicAPIError> {
511        self.egs.start_client_credentials_session().await
512    }
513
514    /// Authenticate with client credentials (app-level, no user context).
515    ///
516    /// Uses the launcher's public client ID/secret to obtain an access token
517    /// without any user interaction. The resulting session has limited
518    /// permissions — it can query public endpoints (catalog, service status,
519    /// currencies) but cannot access user-specific data (library, entitlements).
520    ///
521    /// Returns `true` on success, `false` on failure.
522    pub async fn auth_client_credentials(&mut self) -> bool {
523        self.try_auth_client_credentials().await.unwrap_or(false)
524    }
525
526    /// Like [`external_auths`](Self::external_auths), but returns a `Result` instead of swallowing errors.
527    pub async fn try_external_auths(&self, account_id: &str) -> Result<Vec<ExternalAuth>, EpicAPIError> {
528        self.egs.external_auths(account_id).await
529    }
530
531    /// Fetch external auth connections linked to an account.
532    ///
533    /// Returns linked platform accounts (Steam, PSN, Xbox, Nintendo, etc.)
534    /// with external display names and account IDs. Requires a valid user session.
535    ///
536    /// Returns `None` on API errors.
537    pub async fn external_auths(&self, account_id: &str) -> Option<Vec<ExternalAuth>> {
538        self.try_external_auths(account_id).await.ok()
539    }
540
541    /// Like [`sso_domains`](Self::sso_domains), but returns a `Result` instead of swallowing errors.
542    pub async fn try_sso_domains(&self) -> Result<Vec<String>, EpicAPIError> {
543        self.egs.sso_domains().await
544    }
545
546    /// Fetch the list of SSO (Single Sign-On) domains.
547    ///
548    /// Returns domain strings that support Epic's SSO flow. Used by the
549    /// launcher to determine which domains can share authentication cookies.
550    ///
551    /// Returns `None` on API errors.
552    pub async fn sso_domains(&self) -> Option<Vec<String>> {
553        self.try_sso_domains().await.ok()
554    }
555
556    /// Like [`catalog_items`](Self::catalog_items), but returns a `Result` instead of swallowing errors.
557    pub async fn try_catalog_items(
558        &self,
559        namespace: &str,
560        start: i64,
561        count: i64,
562    ) -> Result<CatalogItemPage, EpicAPIError> {
563        self.egs.catalog_items(namespace, start, count).await
564    }
565
566    /// Fetch paginated catalog items for a namespace.
567    ///
568    /// Queries the Epic catalog service for items within a given namespace
569    /// (e.g., a game's namespace). Results are paginated — use `start` and
570    /// `count` to page through. Each [`CatalogItemPage`] includes a `paging`
571    /// field with the total count.
572    ///
573    /// Returns `None` on API errors.
574    pub async fn catalog_items(
575        &self,
576        namespace: &str,
577        start: i64,
578        count: i64,
579    ) -> Option<CatalogItemPage> {
580        self.try_catalog_items(namespace, start, count).await.ok()
581    }
582
583    /// Like [`catalog_offers`](Self::catalog_offers), but returns a `Result` instead of swallowing errors.
584    pub async fn try_catalog_offers(
585        &self,
586        namespace: &str,
587        start: i64,
588        count: i64,
589    ) -> Result<CatalogOfferPage, EpicAPIError> {
590        self.egs.catalog_offers(namespace, start, count).await
591    }
592
593    /// Fetch paginated catalog offers for a namespace.
594    ///
595    /// Queries the Epic catalog service for offers (purchasable items) within
596    /// a namespace. Offers include pricing metadata, seller info, and linked
597    /// catalog items. Use `start` and `count` to paginate.
598    ///
599    /// Returns `None` on API errors.
600    pub async fn catalog_offers(
601        &self,
602        namespace: &str,
603        start: i64,
604        count: i64,
605    ) -> Option<CatalogOfferPage> {
606        self.try_catalog_offers(namespace, start, count).await.ok()
607    }
608
609    /// Like [`bulk_catalog_items`](Self::bulk_catalog_items), but returns a `Result` instead of swallowing errors.
610    pub async fn try_bulk_catalog_items(
611        &self,
612        items: &[(&str, &str)],
613    ) -> Result<std::collections::HashMap<String, std::collections::HashMap<String, AssetInfo>>, EpicAPIError> {
614        self.egs.bulk_catalog_items(items).await
615    }
616
617    /// Bulk fetch catalog items across multiple namespaces.
618    ///
619    /// Accepts a slice of `(namespace, item_id)` pairs and returns them grouped
620    /// by namespace → item_id → [`AssetInfo`]. Useful for resolving catalog
621    /// metadata for items from different games in a single request.
622    ///
623    /// Returns `None` on API errors.
624    pub async fn bulk_catalog_items(
625        &self,
626        items: &[(&str, &str)],
627    ) -> Option<std::collections::HashMap<String, std::collections::HashMap<String, AssetInfo>>> {
628        self.try_bulk_catalog_items(items).await.ok()
629    }
630
631    /// Like [`currencies`](Self::currencies), but returns a `Result` instead of swallowing errors.
632    pub async fn try_currencies(&self, start: i64, count: i64) -> Result<CurrencyPage, EpicAPIError> {
633        self.egs.currencies(start, count).await
634    }
635
636    /// Fetch available currencies from the Epic catalog.
637    ///
638    /// Returns paginated currency definitions including code, symbol, and
639    /// decimal precision. Use `start` and `count` to paginate.
640    ///
641    /// Returns `None` on API errors.
642    pub async fn currencies(&self, start: i64, count: i64) -> Option<CurrencyPage> {
643        self.try_currencies(start, count).await.ok()
644    }
645
646    /// Like [`library_state_token_status`](Self::library_state_token_status), but returns a `Result` instead of swallowing errors.
647    pub async fn try_library_state_token_status(
648        &self,
649        token_id: &str,
650    ) -> Result<bool, EpicAPIError> {
651        self.egs.library_state_token_status(token_id).await
652    }
653
654    /// Check the validity of a library state token.
655    ///
656    /// Returns `Some(true)` if the token is still valid, `Some(false)` if
657    /// expired or invalid, or `None` on API errors. Library state tokens are
658    /// used to detect changes to the user's library since the last sync.
659    ///
660    /// Returns `None` on API errors.
661    pub async fn library_state_token_status(&self, token_id: &str) -> Option<bool> {
662        self.try_library_state_token_status(token_id).await.ok()
663    }
664
665    /// Like [`service_status`](Self::service_status), but returns a `Result` instead of swallowing errors.
666    pub async fn try_service_status(
667        &self,
668        service_id: &str,
669    ) -> Result<Vec<ServiceStatus>, EpicAPIError> {
670        self.egs.service_status(service_id).await
671    }
672
673    /// Fetch service status from Epic's lightswitch API.
674    ///
675    /// Returns the operational status of an Epic online service (e.g., a game's
676    /// backend). The response includes whether the service is UP/DOWN, any
677    /// maintenance message, and whether the current user is banned.
678    ///
679    /// Returns `None` on API errors.
680    pub async fn service_status(&self, service_id: &str) -> Option<Vec<ServiceStatus>> {
681        self.try_service_status(service_id).await.ok()
682    }
683
684    /// Like [`offer_prices`](Self::offer_prices), but returns a `Result` instead of swallowing errors.
685    pub async fn try_offer_prices(
686        &self,
687        namespace: &str,
688        offer_ids: &[String],
689        country: &str,
690    ) -> Result<PriceResponse, EpicAPIError> {
691        self.egs.offer_prices(namespace, offer_ids, country).await
692    }
693
694    /// Fetch offer prices from Epic's price engine.
695    ///
696    /// Queries current pricing for one or more offers within a namespace,
697    /// localized to a specific country. The response includes original price,
698    /// discount price, and pre-formatted display strings.
699    ///
700    /// Returns `None` on API errors.
701    pub async fn offer_prices(
702        &self,
703        namespace: &str,
704        offer_ids: &[String],
705        country: &str,
706    ) -> Option<PriceResponse> {
707        self.try_offer_prices(namespace, offer_ids, country).await.ok()
708    }
709
710    /// Like [`quick_purchase`](Self::quick_purchase), but returns a `Result` instead of swallowing errors.
711    pub async fn try_quick_purchase(
712        &self,
713        namespace: &str,
714        offer_id: &str,
715    ) -> Result<QuickPurchaseResponse, EpicAPIError> {
716        self.egs.quick_purchase(namespace, offer_id).await
717    }
718
719    /// Execute a quick purchase (typically for free game claims).
720    ///
721    /// Initiates a purchase order for a free offer. The response contains the
722    /// order ID and its processing status. For paid offers, use the full
723    /// checkout flow in the Epic Games launcher instead.
724    ///
725    /// Returns `None` on API errors.
726    pub async fn quick_purchase(
727        &self,
728        namespace: &str,
729        offer_id: &str,
730    ) -> Option<QuickPurchaseResponse> {
731        self.try_quick_purchase(namespace, offer_id).await.ok()
732    }
733
734    /// Like [`billing_account`](Self::billing_account), but returns a `Result` instead of swallowing errors.
735    pub async fn try_billing_account(&self) -> Result<BillingAccount, EpicAPIError> {
736        self.egs.billing_account().await
737    }
738
739    /// Fetch the default billing account for payment processing.
740    ///
741    /// Returns the account's billing country, which is used to determine
742    /// regional pricing and payment availability.
743    ///
744    /// Returns `None` on API errors.
745    pub async fn billing_account(&self) -> Option<BillingAccount> {
746        self.try_billing_account().await.ok()
747    }
748
749    /// Update the user's presence status.
750    ///
751    /// Sends a PATCH request to update the user's online presence (e.g.,
752    /// "online", "away") and optionally set an activity with custom properties.
753    /// The `session_id` is the OAuth session token from login. Returns `Ok(())`
754    /// on success (204 No Content) or an [`EpicAPIError`] on failure.
755    pub async fn update_presence(
756        &self,
757        session_id: &str,
758        body: &PresenceUpdate,
759    ) -> Result<(), EpicAPIError> {
760        self.egs.update_presence(session_id, body).await
761    }
762
763    /// Like [`fab_file_download_info`](Self::fab_file_download_info), but returns a `Result` instead of swallowing errors.
764    pub async fn try_fab_file_download_info(
765        &self,
766        listing_id: &str,
767        format_id: &str,
768        file_id: &str,
769    ) -> Result<DownloadInfo, EpicAPIError> {
770        self.egs
771            .fab_file_download_info(listing_id, format_id, file_id)
772            .await
773    }
774
775    /// Fetch download info for a specific file within a Fab listing.
776    ///
777    /// Returns signed [`DownloadInfo`] for a single file identified by
778    /// `listing_id`, `format_id`, and `file_id`. Use this for targeted
779    /// downloads of individual files from a Fab asset rather than fetching
780    /// the entire asset manifest.
781    ///
782    /// Returns `None` on API errors.
783    pub async fn fab_file_download_info(
784        &self,
785        listing_id: &str,
786        format_id: &str,
787        file_id: &str,
788    ) -> Option<DownloadInfo> {
789        self.try_fab_file_download_info(listing_id, format_id, file_id)
790            .await
791            .ok()
792    }
793}
794
795#[cfg(test)]
796mod facade_tests {
797    use super::*;
798    use crate::api::types::account::UserData;
799    use chrono::{Duration, Utc};
800
801    #[test]
802    fn new_creates_instance() {
803        let egs = EpicGames::new();
804        assert!(!egs.is_logged_in());
805    }
806
807    #[test]
808    fn default_same_as_new() {
809        let egs = EpicGames::default();
810        assert!(!egs.is_logged_in());
811    }
812
813    #[test]
814    fn user_details_default_empty() {
815        let egs = EpicGames::new();
816        assert!(egs.user_details().access_token.is_none());
817    }
818
819    #[test]
820    fn set_and_get_user_details() {
821        let mut egs = EpicGames::new();
822        let mut ud = UserData::new();
823        ud.display_name = Some("TestUser".to_string());
824        egs.set_user_details(ud);
825        assert_eq!(egs.user_details().display_name, Some("TestUser".to_string()));
826    }
827
828    #[test]
829    fn is_logged_in_expired() {
830        let mut egs = EpicGames::new();
831        let mut ud = UserData::new();
832        ud.expires_at = Some(Utc::now() - Duration::hours(1));
833        egs.set_user_details(ud);
834        assert!(!egs.is_logged_in());
835    }
836
837    #[test]
838    fn is_logged_in_valid() {
839        let mut egs = EpicGames::new();
840        let mut ud = UserData::new();
841        ud.expires_at = Some(Utc::now() + Duration::hours(2));
842        egs.set_user_details(ud);
843        assert!(egs.is_logged_in());
844    }
845
846    #[test]
847    fn is_logged_in_within_600s_threshold() {
848        let mut egs = EpicGames::new();
849        let mut ud = UserData::new();
850        ud.expires_at = Some(Utc::now() + Duration::seconds(500));
851        egs.set_user_details(ud);
852        assert!(!egs.is_logged_in());
853    }
854}