use std::fs::File; use std::io::Write; use std::path::PathBuf; use std::{collections::HashMap, path::Path}; use futures::{stream, StreamExt}; use serde::{Deserialize, Serialize}; use std::error::Error; use steamgriddb_api::query_parameters::{ GridDimentions, MimeType, MimeTypeIcon, MimeTypeLogo, Nsfw, }; use tokio::sync::watch::Sender; // 0.3.1 use steam_shortcuts_util::shortcut::ShortcutOwned; use steamgriddb_api::Client; use super::CachedSearch; use crate::settings::Settings; use crate::steam::{get_shortcuts_for_user, get_users_images, SteamUsersInfo}; use crate::steamgriddb::ImageType; use crate::sync::IsBoilRShortcut; use crate::sync::SyncProgress; const CONCURRENT_REQUESTS: usize = 10; impl SearchSettings for Settings { fn download_animated(&self) -> bool { self.steamgrid_db.prefer_animated } fn download_big_picture(&self) -> bool { self.steam.optimize_for_big_picture } fn allow_nsfw(&self) -> bool { self.steamgrid_db.allow_nsfw } fn only_download_boilr_images(&self) -> bool { self.steamgrid_db.only_download_boilr_images } fn is_image_banned(&self, image_type: &ImageType, app_id: u32) -> bool { self.steamgrid_db.is_image_banned(image_type, app_id) } } pub async fn download_images_for_users<'b>( settings: &Settings, users: &[SteamUsersInfo], sender: &mut Option>, ) { let auth_key = &settings.steamgrid_db.auth_key; if let Some(auth_key) = auth_key { println!("Checking for game images"); let start_time = std::time::Instant::now(); let client = steamgriddb_api::Client::new(auth_key); let search = CachedSearch::new(&client); let search = &search; let client = &client; if let Some(sender) = sender { let _ = sender.send(SyncProgress::FindingImages); } let users_info = users.iter().filter_map(|user| { let shortcut_info = get_shortcuts_for_user(user); shortcut_info .map(|shortcut_info| { let data_folder = &user.steam_user_data_folder; (shortcut_info, data_folder) }) .ok() }); let to_downloads = stream::iter(users_info) .map(|(shortcut_info, data_folder)| async move { let known_images = get_users_images(data_folder).unwrap_or_default(); let res = search_for_images_to_download( known_images, data_folder.as_str(), &shortcut_info.shortcuts, search, client, settings, ) .await; res.unwrap_or_default() }) .buffer_unordered(CONCURRENT_REQUESTS) .collect::>>() .await; let to_downloads = to_downloads.iter().flatten().collect::>(); let total = to_downloads.len(); if !to_downloads.is_empty() { if let Some(sender) = sender { let _ = sender.send(SyncProgress::DownloadingImages { to_download: total }); } search.save(); stream::iter(&to_downloads) .map(|to_download| async move { if let Err(e) = download_to_download(to_download).await { println!("Error downloading {:?}: {}", &to_download.path, e); } }) .buffer_unordered(CONCURRENT_REQUESTS) .collect::>() .await; let duration = start_time.elapsed(); println!("Finished getting images in: {duration:?}"); //Validate that the downloads where ok for to_download in to_downloads { let file_length = std::fs::metadata(&to_download.path) .map(|m| m.len()) .unwrap_or_default(); if file_length < 2 { // Image is too small, something went wrong //Try to delete file again, don't care if it fails let _ = std::fs::remove_file(&to_download.path); } } } else { println!("No images needed"); } } else { println!("Steamgrid DB Auth Key not found, please add one as described here: https://github.com/PhilipK/steam_shortcuts_sync#configuration"); } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PublicGameResponseMetadata { store_asset_mtime: Option, clienticon: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PublicGameResponseSteam { id: String, metadata: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PublicGameResponsePlatforms { steam: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PublicGameResponseData { platforms: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PublicGameResponse { success: bool, data: Option, } pub trait SearchSettings { fn download_animated(&self) -> bool; fn download_big_picture(&self) -> bool; fn allow_nsfw(&self) -> bool; fn only_download_boilr_images(&self) -> bool; fn is_image_banned(&self, image_type: &ImageType, app_id: u32) -> bool; } async fn search_for_images_to_download( known_images: Vec, user_data_folder: &str, shortcuts: &[ShortcutOwned], search: &CachedSearch<'_>, client: &Client, search_settins: &T, ) -> Result, Box> { let types = { let mut types = vec![ ImageType::Logo, ImageType::Hero, ImageType::Grid, ImageType::WideGrid, ImageType::Icon, ]; if search_settins.download_big_picture() { types.push(ImageType::BigPicture); } types }; let shortcuts_to_search_for = shortcuts .iter() .filter(|s| !search_settins.only_download_boilr_images() || s.is_boilr_shortcut()) .filter(|s| { // if we are missing any of the images we need to search for them types .iter() .map(|t| t.file_name_no_extension(s.app_id)) .any(|image| !known_images.contains(&image)) && !s.app_name.is_empty() }); let shortcuts_to_search_for: Vec<&ShortcutOwned> = shortcuts_to_search_for.collect(); if shortcuts_to_search_for.is_empty() { return Ok(vec![]); } let mut search_results = HashMap::new(); let search_results_a = stream::iter(shortcuts_to_search_for) .map(|s| async move { let search_result = search.search(s.app_id, &s.app_name).await; match search_result { Ok(Some(search_result)) => Some((s.app_id, search_result)), _ => None, } }) .buffer_unordered(CONCURRENT_REQUESTS) .collect::>>() .await; for (app_id, search) in search_results_a.into_iter().flatten() { search_results.insert(app_id, search); } let mut to_download = vec![]; let grid_folder = Path::new(user_data_folder).join("config").join("grid"); for image_type in types { let images_needed = shortcuts .iter() .filter(|s| search_results.contains_key(&s.app_id)) .filter(|s| !search_settins.is_image_banned(&image_type, s.app_id)) .filter(|s| !known_images.contains(&image_type.file_name_no_extension(s.app_id))); let image_ids: Vec = images_needed .clone() .filter_map(|s| search_results.get(&s.app_id)) .copied() .collect(); let shortcuts: Vec<&ShortcutOwned> = images_needed.collect(); for image_ids in image_ids.chunks(99) { let image_search_result = get_images_for_ids( client, image_ids, &image_type, search_settins.download_animated(), search_settins.allow_nsfw(), ) .await; match image_search_result { Ok(images) => { let images = images.iter().enumerate().filter_map(|(index, image)| { if let (Some(shortcut), Some(image_id)) = (shortcuts.get(index), image_ids.get(index)) { Some((image, shortcut, image_id)) } else { None } }); let download_for_this_type = stream::iter(images) .filter_map(|(image, shortcut, game_id)| { let extension = image .as_ref() .map(|image| get_image_extension(&image.mime)) .unwrap_or("png"); let path = grid_folder.join(image_type.file_name(shortcut.app_id, extension)); async move { let image_url = match image { Ok(img) => Some(img.url.clone()), Err(_) => get_steam_image_url(*game_id, &image_type).await, }; image_url.map(|url| ToDownload { path, url, app_name: shortcut.app_name.clone(), image_type, }) } }) .collect::>() .await; to_download.extend(download_for_this_type); } Err(err) => eprintln!("Error getting images: {err}"), } } } Ok(to_download) } pub fn get_image_extension(mime_type: &steamgriddb_api::images::MimeTypes) -> &'static str { match mime_type { steamgriddb_api::images::MimeTypes::Default(MimeType::Jpeg) => "jpg", steamgriddb_api::images::MimeTypes::Default(MimeType::Png) => "png", steamgriddb_api::images::MimeTypes::Default(MimeType::Webp) => "webp", steamgriddb_api::images::MimeTypes::Logo(MimeTypeLogo::Png) => "png", steamgriddb_api::images::MimeTypes::Logo(MimeTypeLogo::Webp) => "webp", steamgriddb_api::images::MimeTypes::Icon(MimeTypeIcon::Icon) => "ico", steamgriddb_api::images::MimeTypes::Icon(MimeTypeIcon::Png) => "png", } } async fn get_images_for_ids( client: &Client, image_ids: &[usize], image_type: &ImageType, download_animated: bool, allow_nsfw: bool, ) -> Result>, String> { let query_type = get_query_type(download_animated, image_type, allow_nsfw); let image_search_result = client.get_images_for_ids(image_ids, &query_type).await; image_search_result.map_err(|e| format!("Image search failed {e:?}")) } const BIG_PICTURE_DIMS: [GridDimentions; 2] = [GridDimentions::D920x430, GridDimentions::D460x215]; pub fn get_query_type( download_animated: bool, image_type: &ImageType, allow_nsfw: bool, ) -> steamgriddb_api::QueryType { let anymation_type = if download_animated { Some(&[steamgriddb_api::query_parameters::AnimtionType::Animated][..]) } else { None }; use steamgriddb_api::query_parameters::GridQueryParameters; let allow_nsfw_enum = match allow_nsfw { true => Some(&Nsfw::Any), false => Some(&Nsfw::False), }; let big_picture_parameters = GridQueryParameters { dimentions: Some(&BIG_PICTURE_DIMS), types: anymation_type, nsfw: allow_nsfw_enum, ..Default::default() }; use steamgriddb_api::query_parameters::HeroQueryParameters; let hero_parameters = HeroQueryParameters { types: anymation_type, nsfw: allow_nsfw_enum, ..Default::default() }; let grid_parameters = GridQueryParameters { types: anymation_type, nsfw: allow_nsfw_enum, ..Default::default() }; use steamgriddb_api::query_parameters::LogoQueryParameters; let logo_parameters = LogoQueryParameters { types: anymation_type, nsfw: allow_nsfw_enum, ..Default::default() }; use steamgriddb_api::query_parameters::IconQueryParameters; let icon_parameters = IconQueryParameters { nsfw: allow_nsfw_enum, ..Default::default() }; let query_type = match image_type { ImageType::Hero => steamgriddb_api::QueryType::Hero(Some(hero_parameters)), ImageType::BigPicture => steamgriddb_api::QueryType::Grid(Some(big_picture_parameters)), ImageType::Grid => steamgriddb_api::QueryType::Grid(Some(grid_parameters)), ImageType::WideGrid => steamgriddb_api::QueryType::Grid(Some(big_picture_parameters)), ImageType::Logo => steamgriddb_api::QueryType::Logo(Some(logo_parameters)), ImageType::Icon => steamgriddb_api::QueryType::Icon(Some(icon_parameters)), }; query_type } async fn get_steam_image_url(game_id: usize, image_type: &ImageType) -> Option { if let ImageType::Icon = image_type { if let Some(url) = get_steam_icon_url(game_id).await { return Some(url); } } let steamgriddb_page_url = format!("https://www.steamgriddb.com/api/public/game/{game_id}/"); let response = reqwest::get(steamgriddb_page_url).await; if let Ok(response) = response { let text_response = response.json::().await; if let Ok(response) = text_response { let game_id = response .data .clone() .map(|d| d.platforms.map(|p| p.steam.map(|s| s.id))); let mtime = response.data.map(|d| { d.platforms .map(|p| p.steam.map(|s| s.metadata.map(|m| m.store_asset_mtime))) }); if let (Some(Some(Some(steam_app_id))), Some(Some(Some(Some(Some(mtime)))))) = (game_id, mtime) { return Some(image_type.steam_url(steam_app_id, mtime)); } } } None } async fn get_steam_icon_url(game_id: usize) -> Option { let steamgriddb_page_url = format!("https://www.steamgriddb.com/api/public/game/{game_id}/"); let response = reqwest::get(steamgriddb_page_url).await; if let Ok(response) = response { let text_response = response.json::().await; if let Ok(response) = text_response { let game_id = response .data .clone() .map(|d| d.platforms.map(|p| p.steam.map(|s| s.id))); let mtime = response.data.map(|d| { d.platforms .map(|p| p.steam.map(|s| s.metadata.map(|m| m.clienticon))) }); if let (Some(Some(Some(steam_app_id))), Some(Some(Some(Some(Some(mtime)))))) = (game_id, mtime) { return Some(icon_url(&steam_app_id, &mtime)); } } } None } fn icon_url(steam_app_id: &str, icon_id: &str) -> String { format!( "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/{steam_app_id}/{icon_id}.ico" ) } pub async fn download_to_download(to_download: &ToDownload) -> eyre::Result<()> { println!( "Downloading {:?} for {} to {:?}", to_download.image_type, to_download.app_name, to_download.path ); let path = &to_download.path; let url = &to_download.url; let mut file = File::create(path)?; let response = reqwest::get(url).await?; let content = response.bytes().await?; file.write_all(&content)?; Ok(()) } pub struct ToDownload { pub path: PathBuf, pub url: String, pub app_name: String, pub image_type: ImageType, }