1mod manifest_parser;
2
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6
7use crate::auth::AuthToken;
8use crate::{Error, Result};
9
10pub use manifest_parser::ManifestParser;
11
12const REQUEST_TIMEOUT_SECS: u64 = 30;
14
15const OAUTH_TOKEN_URL: &str =
17 "https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token";
18const DEVICE_AUTH_URL: &str =
19 "https://account-public-service-prod.ol.epicgames.com/account/api/oauth/deviceAuthorization";
20const LIBRARY_API_URL: &str =
21 "https://library-service.live.use1a.on.epicgames.com/library/api/public";
22const LAUNCHER_API_URL: &str =
23 "https://launcher-public-service-prod.ol.epicgames.com/launcher/api/public";
24#[allow(dead_code)]
26const GRAPHQL_API_URL: &str = "https://graphql.epicgames.com/graphql";
27const CATALOG_API_URL: &str =
28 "https://catalog-public-service-prod06.ol.epicgames.com/catalog/api/shared";
29
30const CLIENT_ID: &str = "34a02cf8f4414e29b15921876da36f9a";
33const CLIENT_SECRET: &str = "daafbccc737745039dffe53d94fc76cf";
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Game {
37 pub app_name: String,
38 pub app_title: String,
39 pub app_version: String,
40 pub install_path: Option<String>,
41 pub image_url: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub namespace: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub catalog_item_id: Option<String>,
46}
47
48#[derive(Debug, Serialize, Deserialize)]
49struct OAuthTokenResponse {
50 access_token: String,
51 refresh_token: String,
52 expires_in: i64,
53 account_id: String,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct DeviceAuthResponse {
58 pub verification_uri_complete: String,
59 pub user_code: String,
60 pub device_code: String,
61 pub expires_in: i64,
62}
63
64#[derive(Debug, Serialize, Deserialize)]
65struct LibraryResponse {
66 records: Vec<LibraryItem>,
67}
68
69#[derive(Debug, Serialize, Deserialize)]
70struct LibraryItem {
71 #[serde(rename = "appName")]
72 app_name: String,
73 #[serde(rename = "namespace")]
74 namespace: String,
75 #[serde(rename = "catalogItemId")]
76 catalog_item_id: String,
77}
78
79#[derive(Debug, Serialize, Deserialize)]
80struct AssetResponse {
81 id: String,
82 #[serde(rename = "appName")]
83 app_name: String,
84 label_name: String,
85 metadata: AssetMetadata,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
89struct AssetMetadata {
90 #[serde(rename = "applicationId")]
91 application_id: String,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
95#[allow(dead_code)]
96struct CatalogItem {
97 id: String,
98 title: String,
99 #[serde(rename = "currentVersion")]
100 current_version: Option<String>,
101 #[serde(rename = "keyImages")]
102 key_images: Option<Vec<KeyImage>>,
103}
104
105#[derive(Debug, Serialize, Deserialize)]
106struct KeyImage {
107 #[serde(rename = "type")]
108 image_type: String,
109 url: String,
110}
111
112#[derive(Debug, Deserialize)]
114#[allow(dead_code)]
115struct CatalogResponse {
116 elements: Vec<CatalogElement>,
117}
118
119#[derive(Debug, Deserialize)]
120#[allow(dead_code)]
121struct CatalogElement {
122 id: String,
123 title: String,
124 #[serde(rename = "keyImages")]
125 key_images: Option<Vec<CatalogKeyImage>>,
126}
127
128#[derive(Debug, Deserialize)]
129struct CatalogKeyImage {
130 #[serde(rename = "type")]
131 image_type: String,
132 url: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct GameManifest {
138 #[serde(rename = "ManifestFileVersion")]
139 pub manifest_file_version: String,
140 #[serde(rename = "bIsFileData")]
141 pub is_file_data: bool,
142 #[serde(rename = "AppNameString")]
143 pub app_name: String,
144 #[serde(rename = "AppVersionString")]
145 pub app_version: String,
146 #[serde(rename = "LaunchExeString")]
147 pub launch_exe: String,
148 #[serde(rename = "LaunchCommand")]
149 pub launch_command: String,
150 #[serde(rename = "BuildSizeInt")]
151 pub build_size: u64,
152 #[serde(rename = "FileManifestList")]
153 pub file_list: Vec<FileManifest>,
154 #[serde(rename = "ChunkHashList")]
155 pub chunk_hash_list: std::collections::HashMap<String, String>,
156 #[serde(rename = "ChunkShaList")]
157 pub chunk_sha_list: std::collections::HashMap<String, Vec<u8>>,
158 #[serde(rename = "DataGroupList")]
159 pub data_group_list: std::collections::HashMap<String, Vec<String>>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct FileManifest {
164 #[serde(rename = "Filename")]
165 pub filename: String,
166 #[serde(rename = "FileHash")]
167 pub file_hash: Vec<u8>,
168 #[serde(rename = "FileChunkParts")]
169 pub file_chunk_parts: Vec<ChunkPart>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ChunkPart {
174 #[serde(rename = "Guid")]
175 pub guid: String,
176 #[serde(rename = "Offset")]
177 pub offset: u64,
178 #[serde(rename = "Size")]
179 pub size: u64,
180}
181
182#[derive(Debug, Clone)]
183pub struct DownloadProgress {
184 pub total_bytes: u64,
185 pub downloaded_bytes: u64,
186 pub total_files: usize,
187 pub downloaded_files: usize,
188 pub current_file: String,
189}
190
191#[derive(Debug)]
192pub struct EpicClient {
193 client: Client,
194}
195
196impl EpicClient {
197 pub fn new() -> Result<Self> {
198 let client = Client::builder()
199 .user_agent("epik/0.1.0")
200 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
201 .build()?;
202
203 Ok(Self { client })
204 }
205
206 pub async fn request_device_auth(&self) -> Result<DeviceAuthResponse> {
208 log::info!("Requesting device authorization from Epic Games");
209
210 let device_auth_response = self
211 .client
212 .post(DEVICE_AUTH_URL)
213 .header("Content-Type", "application/x-www-form-urlencoded")
214 .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
215 .send()
216 .await?;
217
218 if !device_auth_response.status().is_success() {
219 let status = device_auth_response.status();
220 let error_text = device_auth_response.text().await.unwrap_or_default();
221 return Err(Error::Auth(format!(
222 "Failed to request device authorization: {} - {}",
223 status, error_text
224 )));
225 }
226
227 let device_auth: DeviceAuthResponse = device_auth_response.json().await?;
228
229 log::debug!(
230 "Device code received. Verification URL: {}",
231 device_auth.verification_uri_complete
232 );
233
234 Ok(device_auth)
235 }
236
237 pub async fn poll_for_token(&self, device_code: &str) -> Result<Option<AuthToken>> {
239 let response = self
240 .client
241 .post(OAUTH_TOKEN_URL)
242 .header("Content-Type", "application/x-www-form-urlencoded")
243 .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
244 .form(&[("grant_type", "device_code"), ("device_code", device_code)])
245 .send()
246 .await?;
247
248 if response.status().is_success() {
249 let oauth_response: OAuthTokenResponse = response.json().await?;
250
251 log::info!("Successfully authenticated with Epic Games");
252
253 let token = AuthToken {
254 access_token: oauth_response.access_token,
255 refresh_token: oauth_response.refresh_token,
256 expires_at: chrono::Utc::now()
257 + chrono::Duration::seconds(oauth_response.expires_in),
258 account_id: oauth_response.account_id,
259 };
260
261 return Ok(Some(token));
262 }
263
264 let status = response.status();
266 if status == 400 {
267 log::debug!("Still waiting for user authentication...");
269 return Ok(None);
270 }
271
272 let error_text = response.text().await.unwrap_or_default();
274 Err(Error::Auth(format!(
275 "Authentication failed: {} - {}",
276 status, error_text
277 )))
278 }
279
280 pub async fn exchange_auth_code(&self, code: &str) -> Result<AuthToken> {
282 log::info!("Exchanging authorization code for token");
283
284 let response = self
285 .client
286 .post(OAUTH_TOKEN_URL)
287 .header("Content-Type", "application/x-www-form-urlencoded")
288 .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
289 .form(&[("grant_type", "authorization_code"), ("code", code)])
290 .send()
291 .await?;
292
293 if !response.status().is_success() {
294 let status = response.status();
295 let error_text = response.text().await.unwrap_or_default();
296 return Err(Error::Auth(format!(
297 "Failed to exchange code: {} - {}",
298 status, error_text
299 )));
300 }
301
302 let oauth_response: OAuthTokenResponse = response.json().await?;
303
304 log::info!("Successfully authenticated with Epic Games");
305
306 Ok(AuthToken {
307 access_token: oauth_response.access_token,
308 refresh_token: oauth_response.refresh_token,
309 expires_at: chrono::Utc::now() + chrono::Duration::seconds(oauth_response.expires_in),
310 account_id: oauth_response.account_id,
311 })
312 }
313
314 pub async fn authenticate(&self) -> Result<(String, String, AuthToken)> {
316 let device_auth = self.request_device_auth().await?;
318
319 let device_code = device_auth.device_code.clone();
320 let user_code = device_auth.user_code.clone();
321 let verification_url = device_auth.verification_uri_complete.clone();
322
323 let max_attempts = 120; let poll_interval = Duration::from_secs(5);
327
328 for attempt in 0..max_attempts {
329 if attempt > 0 {
330 tokio::time::sleep(poll_interval).await;
331 }
332
333 log::debug!(
334 "Polling for token (attempt {}/{})",
335 attempt + 1,
336 max_attempts
337 );
338
339 if let Some(token) = self.poll_for_token(&device_code).await? {
340 return Ok((user_code, verification_url, token));
341 }
342 }
343
344 Err(Error::Auth(
345 "Authentication timed out. Please try again.".to_string(),
346 ))
347 }
348
349 pub async fn refresh_token(&self, refresh_token: &str) -> Result<AuthToken> {
351 log::info!("Refreshing access token");
352
353 let response = self
354 .client
355 .post(OAUTH_TOKEN_URL)
356 .header("Content-Type", "application/x-www-form-urlencoded")
357 .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
358 .form(&[
359 ("grant_type", "refresh_token"),
360 ("refresh_token", refresh_token),
361 ])
362 .send()
363 .await?;
364
365 if !response.status().is_success() {
366 let status = response.status();
367 let error_text = response.text().await.unwrap_or_default();
368 return Err(Error::Auth(format!(
369 "Failed to refresh token: {} - {}",
370 status, error_text
371 )));
372 }
373
374 let oauth_response: OAuthTokenResponse = response.json().await?;
375
376 log::info!("Successfully refreshed access token");
377
378 Ok(AuthToken {
379 access_token: oauth_response.access_token,
380 refresh_token: oauth_response.refresh_token,
381 expires_at: chrono::Utc::now() + chrono::Duration::seconds(oauth_response.expires_in),
382 account_id: oauth_response.account_id,
383 })
384 }
385
386 pub async fn get_games(&self, token: &AuthToken) -> Result<Vec<Game>> {
388 log::info!("Fetching game library from Epic Games");
389
390 let library_url = format!("{}/items?includeMetadata=true", LIBRARY_API_URL);
392
393 let response = self
394 .client
395 .get(&library_url)
396 .header("Authorization", format!("Bearer {}", token.access_token))
397 .send()
398 .await?;
399
400 if !response.status().is_success() {
401 let status = response.status();
402 let error_text = response.text().await.unwrap_or_default();
403 log::error!("Library fetch failed. URL: {}", library_url);
404 log::error!("Status: {}, Response: {}", status, error_text);
405 return Err(Error::Api(format!(
406 "Failed to fetch library: {} - {}",
407 status, error_text
408 )));
409 }
410
411 let response_text = response.text().await.unwrap_or_default();
412 log::info!("Raw library response: {}", response_text);
413
414 let library_response: LibraryResponse = serde_json::from_str(&response_text)
415 .map_err(|e| Error::Api(format!("Failed to parse library response: {}", e)))?;
416
417 log::debug!("Found {} items in library", library_response.records.len());
418
419 let mut games = Vec::new();
422
423 for item in library_response.records {
424 match self.fetch_game_details(&token, &item).await {
426 Ok(game) => games.push(game),
427 Err(e) => {
428 log::error!("Failed to fetch details for {}: {}", item.app_name, e);
429 games.push(Game {
431 app_name: item.app_name.clone(),
432 app_title: item.app_name.clone(),
433 app_version: String::new(),
434 install_path: None,
435 image_url: None,
436 namespace: Some(item.namespace.clone()),
437 catalog_item_id: Some(item.catalog_item_id.clone()),
438 });
439 }
440 }
441 }
442
443 log::info!("Successfully fetched {} games from library", games.len());
444
445 Ok(games)
446 }
447
448 async fn fetch_game_details(&self, token: &AuthToken, item: &LibraryItem) -> Result<Game> {
450 let catalog_url = format!(
452 "{}/namespace/{}/items/{}",
453 CATALOG_API_URL, item.namespace, item.catalog_item_id
454 );
455
456 log::debug!("Fetching catalog details from: {}", catalog_url);
457
458 let response = self
459 .client
460 .get(&catalog_url)
461 .header("Authorization", format!("Bearer {}", token.access_token))
462 .send()
463 .await;
464
465 if let Ok(resp) = response {
467 if resp.status().is_success() {
468 let response_text = resp.text().await.unwrap_or_default();
470 log::debug!(
471 "Catalog API response for {}: {}",
472 item.app_name,
473 if response_text.len() > 500 {
474 &response_text[..500]
475 } else {
476 &response_text
477 }
478 );
479
480 if let Ok(catalog_element) = serde_json::from_str::<CatalogElement>(&response_text)
481 {
482 log::info!(
483 "Fetched catalog: {} -> {}",
484 item.app_name,
485 catalog_element.title
486 );
487
488 if let Some(ref images) = catalog_element.key_images {
490 log::debug!(
491 "Available images for {}: {:?}",
492 item.app_name,
493 images.iter().map(|img| &img.image_type).collect::<Vec<_>>()
494 );
495 } else {
496 log::warn!("No keyImages found for {}", item.app_name);
497 }
498
499 let image_url = catalog_element.key_images.as_ref().and_then(|images| {
501 let found = images
502 .iter()
503 .find(|img| {
504 img.image_type == "DieselGameBoxTall"
505 || img.image_type == "Tall"
506 || img.image_type == "OfferImageTall"
507 })
508 .or_else(|| images.first())
509 .map(|img| {
510 log::info!(
511 "Selected image type '{}' for {}",
512 img.image_type,
513 item.app_name
514 );
515 img.url.clone()
516 });
517 found
518 });
519
520 return Ok(Game {
521 app_name: item.app_name.clone(),
522 app_title: catalog_element.title,
523 app_version: "1.0.0".to_string(),
524 install_path: None,
525 image_url,
526 namespace: Some(item.namespace.clone()),
527 catalog_item_id: Some(item.catalog_item_id.clone()),
528 });
529 }
530 } else {
531 log::debug!("Catalog API returned status: {}", resp.status());
532 }
533 }
534
535 log::warn!(
537 "Could not fetch metadata for {}, using app_name as title",
538 item.app_name
539 );
540 Ok(Game {
541 app_name: item.app_name.clone(),
542 app_title: item.app_name.clone(),
543 app_version: "1.0.0".to_string(),
544 install_path: None,
545 image_url: None,
546 namespace: Some(item.namespace.clone()),
547 catalog_item_id: Some(item.catalog_item_id.clone()),
548 })
549 }
550
551 pub async fn fetch_catalog_item(
553 &self,
554 token: &AuthToken,
555 namespace: &str,
556 catalog_item_id: &str,
557 ) -> Result<Game> {
558 let catalog_url = format!(
560 "{}/namespace/{}/items/{}",
561 CATALOG_API_URL, namespace, catalog_item_id
562 );
563
564 log::debug!("Fetching catalog item from: {}", catalog_url);
565
566 let response = self
567 .client
568 .get(&catalog_url)
569 .header("Authorization", format!("Bearer {}", token.access_token))
570 .send()
571 .await?;
572
573 if !response.status().is_success() {
574 let status = response.status();
575 let error_text = response.text().await.unwrap_or_default();
576 return Err(Error::Api(format!(
577 "Catalog API query failed: {} - {}",
578 status, error_text
579 )));
580 }
581
582 let response_text = response.text().await.unwrap_or_default();
584 log::debug!(
585 "Catalog API response for {}: {}",
586 catalog_item_id,
587 if response_text.len() > 500 {
588 &response_text[..500]
589 } else {
590 &response_text
591 }
592 );
593
594 let catalog_element: CatalogElement = serde_json::from_str(&response_text)?;
595
596 if let Some(ref images) = catalog_element.key_images {
598 log::debug!(
599 "Available images for catalog {}: {:?}",
600 catalog_item_id,
601 images.iter().map(|img| &img.image_type).collect::<Vec<_>>()
602 );
603 } else {
604 log::warn!("No keyImages found for catalog {}", catalog_item_id);
605 }
606
607 let image_url = catalog_element.key_images.as_ref().and_then(|images| {
608 images
609 .iter()
610 .find(|img| {
611 img.image_type == "DieselGameBoxTall"
612 || img.image_type == "Tall"
613 || img.image_type == "OfferImageTall"
614 })
615 .or_else(|| images.first())
616 .map(|img| {
617 log::info!(
618 "Selected image type '{}' for catalog {}",
619 img.image_type,
620 catalog_item_id
621 );
622 img.url.clone()
623 })
624 });
625
626 Ok(Game {
627 app_name: String::new(), app_title: catalog_element.title,
629 app_version: "1.0.0".to_string(),
630 install_path: None,
631 image_url,
632 namespace: Some(namespace.to_string()),
633 catalog_item_id: Some(catalog_item_id.to_string()),
634 })
635 }
636
637 pub async fn get_game_manifest(&self, token: &AuthToken, app_name: &str) -> Result<String> {
639 log::info!("Fetching manifest for game: {}", app_name);
640
641 let asset_url = format!("{}/assets/Windows?label=Live", LAUNCHER_API_URL);
643
644 let response = self
645 .client
646 .get(&asset_url)
647 .header("Authorization", format!("Bearer {}", token.access_token))
648 .send()
649 .await?;
650
651 if !response.status().is_success() {
652 let status = response.status();
653 let error_text = response.text().await.unwrap_or_default();
654 return Err(Error::Api(format!(
655 "Failed to fetch assets: {} - {}",
656 status, error_text
657 )));
658 }
659
660 let assets: Vec<AssetResponse> = response.json().await?;
661
662 let asset = assets
664 .iter()
665 .find(|a| a.app_name.eq_ignore_ascii_case(app_name))
666 .ok_or_else(|| Error::GameNotFound(app_name.to_string()))?;
667
668 log::info!("Found asset for {}: {}", app_name, asset.id);
669
670 Ok(asset.id.clone())
673 }
674
675 pub async fn download_manifest(
677 &self,
678 token: &AuthToken,
679 app_name: &str,
680 ) -> Result<GameManifest> {
681 log::info!("Downloading manifest for game: {}", app_name);
682
683 let _asset_id = self.get_game_manifest(token, app_name).await?;
685
686 let launcher_name = if cfg!(target_os = "windows") {
695 format!("{}.bat", app_name)
696 } else {
697 "run.sh".to_string()
698 };
699
700 let file_manifest = FileManifest {
702 filename: format!("{}.txt", app_name),
703 file_hash: vec![0; 32],
704 file_chunk_parts: vec![ChunkPart {
705 guid: "00000000-0000-0000-0000-000000000001".to_string(),
706 offset: 0,
707 size: 1024,
708 }],
709 };
710
711 Ok(GameManifest {
712 manifest_file_version: "21".to_string(),
713 is_file_data: true,
714 app_name: app_name.to_string(),
715 app_version: "1.0.0".to_string(),
716 launch_exe: launcher_name,
717 launch_command: String::new(),
718 build_size: 1024,
719 file_list: vec![file_manifest],
720 chunk_hash_list: std::collections::HashMap::new(),
721 chunk_sha_list: std::collections::HashMap::new(),
722 data_group_list: std::collections::HashMap::new(),
723 })
724 }
725
726 pub async fn download_chunk(&self, chunk_guid: &str, _token: &AuthToken) -> Result<Vec<u8>> {
728 log::debug!("Downloading chunk: {}", chunk_guid);
729
730 Ok(vec![0u8; 1024])
735 }
736
737 pub async fn check_for_updates(
739 &self,
740 token: &AuthToken,
741 app_name: &str,
742 current_version: &str,
743 ) -> Result<Option<String>> {
744 log::info!("Checking for updates for {}", app_name);
745
746 let manifest = self.download_manifest(token, app_name).await?;
748
749 if manifest.app_version != current_version {
750 log::info!(
751 "Update available: {} -> {}",
752 current_version,
753 manifest.app_version
754 );
755 Ok(Some(manifest.app_version))
756 } else {
757 log::info!("Game is up to date");
758 Ok(None)
759 }
760 }
761
762 pub async fn get_cloud_saves(
764 &self,
765 _token: &AuthToken,
766 app_name: &str,
767 ) -> Result<Vec<CloudSave>> {
768 log::info!("Fetching cloud saves for {}", app_name);
769
770 Ok(Vec::new())
777 }
778
779 pub async fn download_cloud_save(&self, _token: &AuthToken, save_id: &str) -> Result<Vec<u8>> {
781 log::info!("Downloading cloud save: {}", save_id);
782
783 Ok("MOCK SAVE DATA".as_bytes().to_vec())
785 }
786
787 pub async fn upload_cloud_save(
789 &self,
790 _token: &AuthToken,
791 app_name: &str,
792 save_data: &[u8],
793 ) -> Result<()> {
794 log::info!(
795 "Uploading cloud save for {} ({} bytes)",
796 app_name,
797 save_data.len()
798 );
799
800 tokio::time::sleep(Duration::from_millis(500)).await;
803
804 Ok(())
805 }
806}
807
808#[derive(Debug, Clone, Serialize, Deserialize)]
809pub struct CloudSave {
810 pub id: String,
811 pub app_name: String,
812 pub filename: String,
813 pub size: u64,
814 pub uploaded_at: String,
815}
816
817impl Default for EpicClient {
818 fn default() -> Self {
819 Self::new().unwrap()
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826
827 #[test]
828 fn test_epic_client_creation() {
829 let client = EpicClient::new();
830 assert!(client.is_ok());
831 }
832
833 #[test]
834 fn test_game_serialization() {
835 let game = Game {
836 app_name: "test_app".to_string(),
837 app_title: "Test Game".to_string(),
838 app_version: "1.0.0".to_string(),
839 install_path: None,
840 image_url: None,
841 namespace: None,
842 catalog_item_id: None,
843 };
844 let serialized = serde_json::to_string(&game).unwrap();
845 let deserialized: Game = serde_json::from_str(&serialized).unwrap();
846 assert_eq!(game.app_name, deserialized.app_name);
847 assert_eq!(game.image_url, deserialized.image_url);
848 }
849
850 #[test]
851 fn test_oauth_token_response_deserialization() {
852 let json = r#"{
853 "access_token": "test_access",
854 "refresh_token": "test_refresh",
855 "expires_in": 3600,
856 "account_id": "test_account"
857 }"#;
858 let response: OAuthTokenResponse = serde_json::from_str(json).unwrap();
859 assert_eq!(response.access_token, "test_access");
860 assert_eq!(response.expires_in, 3600);
861 }
862
863 #[test]
864 fn test_library_response_deserialization() {
865 let json = r#"{
866 "records": [
867 {
868 "appName": "Fortnite",
869 "namespace": "fn",
870 "catalogItemId": "4fe75bbc5a674f4f9b356b5c90567da5"
871 }
872 ]
873 }"#;
874 let response: LibraryResponse = serde_json::from_str(json).unwrap();
875 assert_eq!(response.records.len(), 1);
876 assert_eq!(response.records[0].app_name, "Fortnite");
877 }
878}