487 lines
18 KiB
Rust
487 lines
18 KiB
Rust
//! 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::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<ConfigError> for GogError {
|
|
fn from(e: ConfigError) -> Self {
|
|
GogError::ConfigError(e)
|
|
}
|
|
}
|
|
|
|
impl From<HttpError> for GogError {
|
|
fn from(e: HttpError) -> Self {
|
|
GogError::HttpError(e)
|
|
}
|
|
}
|
|
|
|
impl From<serde_json::Error> for GogError {
|
|
fn from(e: serde_json::Error) -> Self {
|
|
GogError::SerdeError(e)
|
|
}
|
|
}
|
|
|
|
impl From<io::Error> 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<String>,
|
|
language_filters: &Vec<String>,
|
|
resolution_filters: &Vec<String>,
|
|
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)?;
|
|
|
|
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(content.cd_key.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<String, GogError> {
|
|
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<Token, GogError> {
|
|
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<Token, GogError> {
|
|
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<Vec<u64>, 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<u64> = 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 get_content(&mut self,
|
|
content_id: u64,
|
|
os_filters: &Vec<String>,
|
|
language_filters: &Vec<String>,
|
|
resolution_filters: &Vec<String>)
|
|
-> Result<Content, GogError> {
|
|
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;
|
|
|
|
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)
|
|
}
|
|
}
|