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}