//! Uses the GOG api as described [here](https://gogapidocs.readthedocs.io/en/latest/). //! //! # Example //! ``` //! use gog::Gog; //! //! let mut http_client = Http::new(); //! let mut gog = Gog::new(&mut http_client); //! gog.login(); //! gog.sync(download_folder); //! ``` use configfiles::{ConfigFiles, ConfigError}; use http::{Http, HttpError}; use models; use models::{Token, Content, Data, Config}; use serde_json; use serde_json::Value; use std::collections::HashMap; use std::collections::BTreeMap; use std::fmt; use std::fs; use std::fs::File; use std::io; use std::io::Write; use std::path::Path; /// Wraps `ConfigError`, `HttpError`, `serde_json::Error`, and `io::Error`. #[derive(Debug)] pub enum GogError { Error(&'static str), ConfigError(ConfigError), HttpError(HttpError), SerdeError(serde_json::Error), IOError(io::Error), } impl fmt::Display for GogError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { GogError::Error(ref err) => fmt::Display::fmt(err, f), GogError::ConfigError(ref err) => fmt::Display::fmt(err, f), GogError::HttpError(ref err) => fmt::Display::fmt(err, f), GogError::SerdeError(ref err) => fmt::Display::fmt(err, f), GogError::IOError(ref err) => fmt::Display::fmt(err, f), } } } impl From for GogError { fn from(e: ConfigError) -> Self { GogError::ConfigError(e) } } impl From for GogError { fn from(e: HttpError) -> Self { GogError::HttpError(e) } } impl From for GogError { fn from(e: serde_json::Error) -> Self { GogError::SerdeError(e) } } impl From for GogError { fn from(e: io::Error) -> Self { GogError::IOError(e) } } /// Wraps the GOG api. /// At the moment Client ID and Client Secret are hardcoded to the galaxy client. pub struct Gog<'a> { client_id: String, client_secret: String, redirect_uri: String, games_uri: String, http_client: &'a mut Http, } impl<'a> Gog<'a> { /// Create a new instance of `Gog`. pub fn new(http_client: &'a mut Http) -> Gog<'a> { Gog { client_id: String::from("46899977096215655"), client_secret: String::from("9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"), redirect_uri: String::from("https://embed.gog.com/on_login_success?origin=client"), games_uri: String::from("https://embed.gog.com/user/data/games"), http_client: http_client, } } /// Logs into a gog account. /// If a token was already saved it should work automatically. Otherwise you /// will get an url which you have to open in a browser. There you have to /// login and copy the code parameter from the next page into the prompt. pub fn login(&mut self) -> Result<(), GogError> { let config = ConfigFiles::new(); let mut token: Token = match config.load("token.json") { Ok(value) => value, Err(_) => { let code = self.get_code()?; self.get_token(code.as_str())? } }; if token.is_expired() { token = self.refresh_token(token.refresh_token.as_str())?; } config.save("token.json", &token)?; let auth_header = format!("Authorization: Bearer {token}", token = token.access_token); self.http_client.add_header(auth_header.as_str())?; Ok(()) } /// Syncs the contents of a gog account with a local folder. /// Uses a hash to figure out whether something has changed. pub fn sync(&mut self, storage_path: &str, storage_path_movies: &str, os_filters: &Vec, language_filters: &Vec, resolution_filters: &Vec, skip_movies: bool, skip_games: bool) -> Result<(), GogError> { let configfiles = ConfigFiles::new(); let mut config: Config = match configfiles.load("config.json") { Ok(value) => value, Err(_) => { error!("Configuration error, generating new one"); Config { game_storage: String::from(storage_path), movie_storage: String::from(storage_path_movies), content: HashMap::new(), data: HashMap::new(), extras: HashMap::new(), os_filters: os_filters.clone(), language_filters: language_filters.clone(), resolution_filters: resolution_filters.clone(), skip_movies: skip_movies, skip_games: skip_games, } } }; let content_ids = self.get_content_ids()?; for content_id in content_ids { let content_hash_saved = match config.content.get(&content_id.to_string()) { Some(value) => value.clone(), None => u64::min_value(), }; let content = match self.get_content(content_id, &os_filters, &language_filters, &resolution_filters) { Ok(value) => value, Err(error) => { error!("{}: {}", &content_id, error); continue; } }; if (content.is_movie && skip_movies) || (!content.is_movie && skip_games) { info!("filtering {}", content.title); continue; } let content_hash = models::get_hash(&content); if content_hash_saved == content_hash { info!("{} already up to date.", &content.title); continue; } let content_root = if content.is_movie { Path::new(storage_path_movies).join(&content.title) } else { Path::new(storage_path).join(&content.title) }; fs::create_dir_all(&content_root)?; let key_root = content_root.join("keys"); if content.cd_keys.len() > 0 { fs::create_dir_all(&key_root)?; } for (key, value) in content.cd_keys { let key_path = key_root.join(format!("{}.txt", key)); let mut key_file = File::create(&key_path)?; key_file.write_all(value.as_bytes())? } for data in content.data { let data_hash_saved = match config.data.get(&data.manual_url) { Some(value) => value.clone(), None => u64::min_value(), }; let data_hash = models::get_hash(&data); if data_hash_saved == data_hash { info!("{} already up to date.", &data.manual_url); continue; } let data_root = Path::new(&content_root).join(&data.language); fs::create_dir_all(&data_root)?; let data_uri = format!("https://embed.gog.com{}", data.manual_url); info!("downloading {} for {}...", &data.manual_url, &content.title); self.http_client.download(data_uri.as_str(), &data_root)?; config.data.insert(data.manual_url, data_hash); configfiles.save("config.json", &config)?; } for extra in content.extras { let extra_hash_saved = match config.extras.get(&extra.manual_url) { Some(value) => value.clone(), None => u64::min_value(), }; let extra_hash = models::get_hash(&extra); if extra_hash_saved == extra_hash { info!("{} already up to date.", &extra.manual_url); continue; } let extra_uri = format!("https://embed.gog.com{}", extra.manual_url); info!("downloading {} for {}...", &extra.name, &content.title); self.http_client.download(extra_uri.as_str(), &content_root)?; config.extras.insert(extra.manual_url, extra_hash); configfiles.save("config.json", &config)?; } config.content.insert(content_id.to_string(), content_hash); configfiles.save("config.json", &config)?; } Ok(()) } fn get_code(&self) -> Result { let auth_uri = self.auth_uri(self.client_id.as_str(), self.redirect_uri.as_str()); println!("Open the following url in a browser, login to your account and paste the \ resulting code parameter."); println!("{}", auth_uri); let mut code = String::new(); print!("Code: "); io::stdout().flush()?; io::stdin() .read_line(&mut code) .expect("Failed to read line"); Ok(code) } fn get_token(&mut self, code: &str) -> Result { let token_uri = self.token_uri(self.client_id.as_str(), self.client_secret.as_str(), code, self.redirect_uri.as_str()); let token_response = match self.http_client.get(token_uri.as_str()) { Ok(value) => value, Err(error) => return Err(GogError::HttpError(error)), }; match serde_json::from_str(&token_response) { Ok(value) => Ok(value), Err(error) => Err(GogError::SerdeError(error)), } } fn refresh_token(&mut self, refresh_token: &str) -> Result { let token_refresh_uri = self.token_refresh_uri(self.client_id.as_str(), self.client_secret.as_str(), refresh_token); let token_response = match self.http_client.get(token_refresh_uri.as_str()) { Ok(value) => value, Err(error) => return Err(GogError::HttpError(error)), }; match serde_json::from_str(&token_response) { Ok(value) => Ok(value), Err(error) => Err(GogError::SerdeError(error)), } } fn get_content_ids(&mut self) -> Result, GogError> { let response = self.http_client.get(self.games_uri.as_str())?; let content_ids_raw: Value = serde_json::from_str(response.as_str())?; let content_ids_serde = &content_ids_raw["owned"]; let mut content_ids: Vec = Vec::new(); if !content_ids_serde.is_array() { return Err(GogError::Error("Error parsing content ids.")); } for content_id in content_ids_serde.as_array().unwrap() { let content_id_parsed = content_id.as_u64().unwrap_or(0); if content_id_parsed == 0 { error!("Cant parse content id {}", content_id); continue; } // The numbers in this list are excluded because they refer to // favourites, promotions and such. if [1, 2, 3, 4, 5].contains(&content_id_parsed) { continue; } content_ids.push(content_id_parsed); } return Ok(content_ids); } fn parse_cd_keys(&self, content_title: &str, raw_cd_keys: &str) -> BTreeMap { let mut cd_keys = BTreeMap::new(); let mut raw_cd_keys_fix = raw_cd_keys.to_owned(); if raw_cd_keys_fix.contains("") { raw_cd_keys_fix = raw_cd_keys_fix.replace("", "
") .replace("", "") .replace("", ""); } if raw_cd_keys_fix.contains("
") { let splitted_keys = raw_cd_keys_fix.split("
"); let mut key_names: Vec = Vec::new(); let mut key_values: Vec = Vec::new(); for (token_index, token) in splitted_keys.enumerate() { if token_index % 2 == 0 { let mut key_name = token.to_owned(); key_name.truncate(token.len() - 1); key_names.push(key_name); } else { key_values.push(token.trim().to_owned()); } } for (index, key_name) in key_names.iter().enumerate() { let key_value = key_values[index].clone(); cd_keys.insert(key_name.clone(), key_value); } } else if !raw_cd_keys_fix.is_empty() { cd_keys.insert(content_title.to_owned(), raw_cd_keys_fix.trim().to_owned()); } return cd_keys; } fn get_content(&mut self, content_id: u64, os_filters: &Vec, language_filters: &Vec, resolution_filters: &Vec) -> Result { let content_uri = self.content_uri(content_id); debug!("looking for information at {}...", &content_uri); let response = self.http_client.get(content_uri.as_str())?; let content_raw: Value = serde_json::from_str(response.as_str())?; debug!("found {:?}", &content_raw); let mut content: Content = serde_json::from_str(&response)?; let downloads = &content_raw["downloads"]; if content_raw.is_object() && !content_raw.as_object().unwrap().contains_key("forumLink") { return Err(GogError::Error("No forumLink property")); } let is_movie = match content_raw["forumLink"].as_str() { Some(value) => value == "https://embed.gog.com/forum/movies", None => false, }; content.is_movie = is_movie; if content_raw.is_object() && content_raw.as_object().unwrap().contains_key("cdKey") { let cd_keys_raw = content_raw["cdKey"].as_str().unwrap(); content.cd_keys = self.parse_cd_keys(&content.title, cd_keys_raw); } debug!("processing installer fields: {:?}", &downloads); for languages in downloads.as_array() { for language in languages { if !language.is_array() || language.as_array().unwrap().len() < 2 { error!("Skipping a language for {}", content.title); continue; } let data_language = match language[0].as_str() { Some(value) => value.to_lowercase(), None => String::default(), }; if data_language.is_empty() { error!("Skipping a language for {}", content.title); continue; } if !is_movie && !language_filters.is_empty() && !language_filters.contains(&data_language) { info!("Skipping {} for {}", &data_language, content.title); continue; } for systems in language[1].as_object() { for system in systems.keys() { if is_movie && !os_filters.is_empty() && !os_filters.contains(system) { info!("Skipping {} {} for {}", &data_language, system, content.title); continue; } for real_downloads in systems.get(system) { for real_download in real_downloads.as_array() { for download in real_download { if !download.is_object() || !download.as_object().unwrap().contains_key("manualUrl") || !download.as_object().unwrap().contains_key("name") { error!("Skipping data for {}", content.title); continue; } let name: &str = download["name"].as_str().unwrap(); if content.is_movie && !resolution_filters.is_empty() { let mut found_resolution = false; for resolution_filter in resolution_filters { let filter = format!("({})", resolution_filter); if name.ends_with(&filter) { found_resolution = true; break; } } if !found_resolution { info!("Skipping {}: not a suitable resolution.", name); continue; } } let data = Data { manual_url: String::from(download["manualUrl"] .as_str() .unwrap()), version: String::from(download["version"] .as_str() .unwrap_or("")), os: system.clone(), language: data_language.clone(), }; content.data.push(data); } } } } } } } Ok(content) } fn auth_uri(&self, client_id: &str, redirect_uri: &str) -> String { format!("https://auth.gog.\ com/auth?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&layout=client2", client_id = client_id, redirect_uri = redirect_uri) } fn token_uri(&self, client_id: &str, client_secret: &str, code: &str, redirect_uri: &str) -> String { format!("https://auth.gog.\ com/token?client_id={client_id}&client_secret={client_secret}&grant_type=authorization_code&code={code}&redirect_uri={redirect_uri}", client_id = client_id, client_secret = client_secret, code = code.trim(), redirect_uri = redirect_uri) } fn token_refresh_uri(&self, client_id: &str, client_secret: &str, refresh_token: &str) -> String { format!("https://auth.gog.\ com/token?client_id={client_id}&client_secret={client_secret}&grant_type=refresh_token&refresh_token={refresh_token}", client_id = client_id, client_secret = client_secret, refresh_token = refresh_token) } fn content_uri(&self, content_id: u64) -> String { format!("https://embed.gog.com/account/gameDetails/{content_id}.json", content_id = content_id) } }