diff --git a/.editorconfig b/.editorconfig index d741e40..fa9ce7c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,6 @@ trim_trailing_whitespace = true insert_final_newline = true indent_style = space indent_size = 4 + +[*.md] +trim_trailing_whitespace = true diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbcf33..606fad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## 0.2.2 (2017-03-22) +- ability to save movies seperately - default value for storage config (fixes #6) +- ability to skip movie and game content +- fix a bug with truncating an http response ## 0.2.1 (2017-03-21) - add instructions for getting the code if there is no token diff --git a/README.md b/README.md index 1deb100..b674676 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # GOG-SYNC +## **Please note that this is alpha software, you should not trust it.** A small tool to synchronize the stuff in a [GOG](https://www.gog.com/) library with a local folder. @@ -7,8 +8,6 @@ It builds on the work of the [unofficial GOG API Documentation](https://gogapido This is the first time I am building something with rust, so beware :) -**Please note that this is alpha software, you should not trust it.** - # Installation Install from [crates.io](https://crates.io). @@ -26,13 +25,68 @@ For example on macOS or Linux ~/.config/gog-sync/config.json ``` -It is in Json format and the only relevant key is `storage`. The rest is information -about content hashes. +A bare configuration with default values before first use: +``` +{ + "gameStorage": ".", + "movieStorage": ".", + "content": {}, + "data": {}, + "extras": {}, + "osFilters": [], + "languageFilters": [], + "skipMovies": false, + "skipGames": false +} +``` + +- *gameStorage*: Where to save games +- *movieStorage*: Where to save movies +- *content*: A map, content id => hash +- *data*: A map, data url => hash +- *extras*: A map, extra url => hash +- *osFilters*: An array of operating systems. If it is not empty, game data is limited to the ones in the list. +Valid values are `linux`, `mac` and `windows` +- *languageFilters*: An array of languages. If it is not empty, game data is limited to the ones in the list. +- *skipMovies*: Whether to skip movie content +- *skipGames*: Whether to skip game content + +An incomplete list of languages on gog: +- `english` +- `český` +- `deutsch` +- `español` +- `français` +- `italiano` +- `magyar` +- `polski` +- `русский` +- `中文` + +You should have no need of changing `content`, `data` or `extras`, as these are +used to determine whether specific content is up to date. # Usage If you want to see the information log while running set `RUST_LOG=info`. +``` +LAGS] [OPTIONS] + +FLAGS: + -h, --help Prints help information + -g, --skip-games Skip game content. + -f, --skip-movies Skip movie content. + -V, --version Prints version information + +OPTIONS: + -s, --game-storage Sets the download folder (defaults to the working directory). + -l, --language Only sync files for this comma seperated list of languages. + -m, --movie-storage Sets the download folder for movies (defaults to the working directory). + -o, --os Only sync files for this comma seperated list of operating systems. + Valid values are 'linux', 'mac' and 'windows'. +``` + --- ``` diff --git a/src/gog.rs b/src/gog.rs index 33d4b71..6190fa7 100644 --- a/src/gog.rs +++ b/src/gog.rs @@ -13,7 +13,7 @@ use configfiles::{ConfigFiles, ConfigError}; use http::{Http, HttpError}; use models; -use models::{Token, Game, Installer, Config}; +use models::{Token, Content, Data, Config}; use serde_json; use serde_json::Value; use std::collections::HashMap; @@ -135,10 +135,10 @@ impl<'a> Gog<'a> { error!("Configuration error, generating new one..."); Config { - storage: String::from(storage_path), + game_storage: String::from(storage_path), movie_storage: String::from(storage_path_movies), - games: HashMap::new(), - installers: HashMap::new(), + content: HashMap::new(), + data: HashMap::new(), extras: HashMap::new(), os_filters: os_filters.clone(), language_filters: language_filters.clone(), @@ -148,75 +148,73 @@ impl<'a> Gog<'a> { } }; - let game_ids = self.get_game_ids()?; + let content_ids = self.get_content_ids()?; - for game_id in game_ids { - let game_hash_saved = match config.games.get(&game_id.to_string()) { + 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 game = match self.get_game(game_id, &os_filters, &language_filters) { + let content = match self.get_content(content_id, &os_filters, &language_filters) { Ok(value) => value, Err(error) => { - error!("{}", error); - break; + error!("{}: {}", &content_id, error); + continue; } }; - if (game.is_movie && skip_movies) || (!game.is_movie && skip_games) { - info!("filtering {}", game.title); + if (content.is_movie && skip_movies) || (!content.is_movie && skip_games) { + info!("filtering {}", content.title); continue; } - let game_hash = models::get_hash(&game); + let content_hash = models::get_hash(&content); - if game_hash_saved == game_hash { - info!("{} already up to date.", &game.title); + if content_hash_saved == content_hash { + info!("{} already up to date.", &content.title); continue; } - let game_root = if game.is_movie { - Path::new(storage_path_movies).join(&game.title) + let content_root = if content.is_movie { + Path::new(storage_path_movies).join(&content.title) } else { - Path::new(storage_path).join(&game.title) + Path::new(storage_path).join(&content.title) }; - fs::create_dir_all(&game_root)?; + fs::create_dir_all(&content_root)?; - if !game.cd_key.is_empty() { - let key_path = Path::new(game_root.as_os_str()).join("key.txt"); + if !content.cd_key.is_empty() { + let key_path = Path::new(content_root.as_os_str()).join("key.txt"); let mut key_file = File::create(&key_path)?; - key_file.write_all(game.cd_key.as_bytes())? + key_file.write_all(content.cd_key.as_bytes())? } - for installer in game.installers { - let installer_hash_saved = match config.installers.get(&installer.manual_url) { + 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 installer_hash = models::get_hash(&installer); + let data_hash = models::get_hash(&data); - if installer_hash_saved == installer_hash { - info!("{} already up to date.", &installer.manual_url); + if data_hash_saved == data_hash { + info!("{} already up to date.", &data.manual_url); continue; } - let installer_root = Path::new(&game_root).join(&installer.language); - fs::create_dir_all(&installer_root)?; + let data_root = Path::new(&content_root).join(&data.language); + fs::create_dir_all(&data_root)?; - let installer_uri = format!("https://embed.gog.com{}", installer.manual_url); + let data_uri = format!("https://embed.gog.com{}", data.manual_url); - info!("downloading {} for {}...", - &installer.manual_url, - &game.title); - self.http_client.download(installer_uri.as_str(), &installer_root)?; + info!("downloading {} for {}...", &data.manual_url, &content.title); + self.http_client.download(data_uri.as_str(), &data_root)?; - config.installers.insert(installer.manual_url, installer_hash); + config.data.insert(data.manual_url, data_hash); configfiles.save("config.json", &config)?; } - for extra in game.extras { + for extra in content.extras { let extra_hash_saved = match config.extras.get(&extra.manual_url) { Some(value) => value.clone(), None => u64::min_value(), @@ -231,14 +229,14 @@ impl<'a> Gog<'a> { let extra_uri = format!("https://embed.gog.com{}", extra.manual_url); - info!("downloading {} for {}...", &extra.name, &game.title); - self.http_client.download(extra_uri.as_str(), &game_root)?; + 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.games.insert(game_id.to_string(), game_hash); + config.content.insert(content_id.to_string(), content_hash); configfiles.save("config.json", &config)?; } @@ -297,86 +295,86 @@ impl<'a> Gog<'a> { } } - fn get_game_ids(&mut self) -> Result, GogError> { + fn get_content_ids(&mut self) -> Result, GogError> { let response = self.http_client.get(self.games_uri.as_str())?; - let game_ids_raw: Value = serde_json::from_str(response.as_str())?; - let game_ids_serde = &game_ids_raw["owned"]; + let content_ids_raw: Value = serde_json::from_str(response.as_str())?; + let content_ids_serde = &content_ids_raw["owned"]; - let mut game_ids: Vec = Vec::new(); + let mut content_ids: Vec = Vec::new(); - if !game_ids_serde.is_array() { - return Err(GogError::Error("Error parsing game ids.")); + if !content_ids_serde.is_array() { + return Err(GogError::Error("Error parsing content ids.")); } - for game_id in game_ids_serde.as_array().unwrap() { - let game_id_parsed = game_id.as_u64().unwrap_or(0); + for content_id in content_ids_serde.as_array().unwrap() { + let content_id_parsed = content_id.as_u64().unwrap_or(0); - if game_id_parsed == 0 { - error!("Cant parse game id {}", game_id); + 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(&game_id_parsed) { + if [1, 2, 3, 4, 5].contains(&content_id_parsed) { continue; } - game_ids.push(game_id_parsed); + content_ids.push(content_id_parsed); } - return Ok(game_ids); + return Ok(content_ids); } - fn get_game(&mut self, - game_id: u64, - os_filters: &Vec, - language_filters: &Vec) - -> Result { - let game_uri = self.game_uri(game_id); - debug!("looking for information at {}...", &game_uri); + fn get_content(&mut self, + content_id: u64, + os_filters: &Vec, + language_filters: &Vec) + -> Result { + let content_uri = self.content_uri(content_id); + debug!("looking for information at {}...", &content_uri); - let response = self.http_client.get(game_uri.as_str())?; + let response = self.http_client.get(content_uri.as_str())?; - let game_raw: Value = serde_json::from_str(response.as_str())?; - debug!("found {:?}", &game_raw); + let content_raw: Value = serde_json::from_str(response.as_str())?; + debug!("found {:?}", &content_raw); - let mut game: Game = serde_json::from_str(&response)?; + let mut content: Content = serde_json::from_str(&response)?; - let downloads = &game_raw["downloads"]; + let downloads = &content_raw["downloads"]; - if game_raw.is_object() && !game_raw.as_object().unwrap().contains_key("forumLink") { + if content_raw.is_object() && !content_raw.as_object().unwrap().contains_key("forumLink") { return Err(GogError::Error("No forumLink property")); } - let is_movie = match game_raw["forumLink"].as_str() { + let is_movie = match content_raw["forumLink"].as_str() { Some(value) => value == "https://embed.gog.com/forum/movies", None => false, }; - game.is_movie = is_movie; + content.is_movie = is_movie; 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 {}", game.title); + error!("Skipping a language for {}", content.title); continue; } - let installer_language = match language[0].as_str() { + let data_language = match language[0].as_str() { Some(value) => value.to_lowercase(), None => String::default(), }; - if installer_language.is_empty() { - error!("Skipping a language for {}", game.title); + if data_language.is_empty() { + error!("Skipping a language for {}", content.title); continue; } if !is_movie && !language_filters.is_empty() && - !language_filters.contains(&installer_language) { - info!("Skipping {} for {}", &installer_language, game.title); + !language_filters.contains(&data_language) { + info!("Skipping {} for {}", &data_language, content.title); continue; } @@ -384,9 +382,9 @@ impl<'a> Gog<'a> { for system in systems.keys() { if is_movie && !os_filters.is_empty() && !os_filters.contains(system) { info!("Skipping {} {} for {}", - &installer_language, + &data_language, system, - game.title); + content.title); continue; } @@ -395,11 +393,11 @@ impl<'a> Gog<'a> { for download in real_download { if !download.is_object() || !download.as_object().unwrap().contains_key("manualUrl") { - error!("Skipping an installer for {}", game.title); + error!("Skipping an installer for {}", content.title); continue; } - let installer = Installer { + let data = Data { manual_url: String::from(download["manualUrl"] .as_str() .unwrap()), @@ -407,10 +405,10 @@ impl<'a> Gog<'a> { .as_str() .unwrap_or("")), os: system.clone(), - language: installer_language.clone(), + language: data_language.clone(), }; - game.installers.push(installer); + content.data.push(data); } } } @@ -419,7 +417,7 @@ impl<'a> Gog<'a> { } } - Ok(game) + Ok(content) } fn auth_uri(&self, client_id: &str, redirect_uri: &str) -> String { @@ -455,8 +453,8 @@ impl<'a> Gog<'a> { refresh_token = refresh_token) } - fn game_uri(&self, game_id: u64) -> String { - format!("https://embed.gog.com/account/gameDetails/{game_id}.json", - game_id = game_id) + fn content_uri(&self, content_id: u64) -> String { + format!("https://embed.gog.com/account/gameDetails/{content_id}.json", + content_id = content_id) } } diff --git a/src/main.rs b/src/main.rs index fc9ca41..aafeff2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,9 +48,9 @@ fn main() { .version("0.2.2") .author("Sebastian Hugentobler ") .about("Synchronizes your gog library to a local folder.") - .arg(Arg::with_name("storage") + .arg(Arg::with_name("game-storage") .short("s") - .long("storage") + .long("game-storage") .value_name("FOLDER") .help("Sets the download folder (defaults to the working directory).") .takes_value(true)) @@ -58,7 +58,7 @@ fn main() { .short("m") .long("movie-storage") .value_name("FOLDER") - .help("Sets the download folder for movies (defaults to the game directory).") + .help("Sets the download folder for movies (defaults to the working directory).") .takes_value(true)) .arg(Arg::with_name("os") .short("o") @@ -91,14 +91,14 @@ fn main() { Err(_) => Config::new(), }; - let download_folder: &str = match matches.value_of("storage") { + let download_folder: &str = match matches.value_of("game-storage") { Some(value) => value, - None => config.storage.as_str(), + None => config.game_storage.as_str(), }; - let download_folder_movies: &str = match matches.value_of("movieStorage") { + let download_folder_movies: &str = match matches.value_of("movie-storage") { Some(value) => value, - None => download_folder, + None => config.movie_storage.as_str(), }; let os_filters: Vec = match matches.value_of("os") { diff --git a/src/models.rs b/src/models.rs index 2e3e43a..2cb0366 100644 --- a/src/models.rs +++ b/src/models.rs @@ -33,13 +33,13 @@ fn timestamp() -> i64 { #[serde(rename_all = "camelCase")] pub struct Config { #[serde(default)] - pub storage: String, + pub game_storage: String, #[serde(default)] pub movie_storage: String, #[serde(default = "default_map")] - pub games: HashMap, + pub content: HashMap, #[serde(default = "default_map")] - pub installers: HashMap, + pub data: HashMap, #[serde(default = "default_map")] pub extras: HashMap, #[serde(default = "default_list")] @@ -63,10 +63,10 @@ fn default_list() -> Vec { impl Config { pub fn new() -> Config { Config { - storage: String::from("."), + game_storage: String::from("."), movie_storage: String::from("."), - games: HashMap::new(), - installers: HashMap::new(), + content: HashMap::new(), + data: HashMap::new(), extras: HashMap::new(), os_filters: Vec::new(), language_filters: Vec::new(), @@ -79,17 +79,17 @@ impl Config { #[derive(Hash)] #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct Game { +pub struct Content { pub title: String, pub cd_key: String, #[serde(skip_deserializing)] pub is_movie: bool, #[serde(skip_deserializing)] - pub installers: Vec, + pub data: Vec, pub extras: Vec, } -impl fmt::Display for Game { +impl fmt::Display for Content { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({})", self.title) } @@ -98,14 +98,14 @@ impl fmt::Display for Game { #[derive(Hash)] #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct Installer { +pub struct Data { pub manual_url: String, pub version: String, pub os: String, pub language: String, } -impl fmt::Display for Installer { +impl fmt::Display for Data { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({}, {}, {}, {})",