diff --git a/.gitignore b/.gitignore
index e27b79f..08b0965 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,9 @@ config.json
venv*
.vscode
igdb-cache.sqlite
-bearer-token.json
\ No newline at end of file
+bearer-token.json
+lib/wcwp_rust/target
+lib/wcwp_rust/Cargo.lock
+lib/whatcanweplay.pyd
+lib/bin
+.wcwp-commit-hash
\ No newline at end of file
diff --git a/__init__.py b/__init__.py
index fe0212e..4d95170 100644
--- a/__init__.py
+++ b/__init__.py
@@ -14,18 +14,51 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+try:
+ import shutil, platform, os
+ path_prefix = os.path.dirname(os.path.abspath(__file__))
+ source = ""
+ dest = ""
+ if platform.system() == "Windows":
+ source = "lib/wcwp_rust/target/release/whatcanweplay.dll"
+ dest = "lib/bin/whatcanweplay.pyd"
+ else:
+ source = "lib/wcwp_rust/target/release/libwhatcanweplay.so"
+ dest = "lib/bin/whatcanweplay.so"
+ source = os.path.join(path_prefix, source)
+ dest = os.path.join(path_prefix, dest)
+
+ if not os.path.exists(dest):
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
+ shutil.copy2(source, dest)
+ else:
+ source_time = os.path.getmtime(source)
+ dest_time = os.path.getmtime(dest)
+ try:
+ if dest_time < source_time:
+ print("Updating WhatCanWePlay rust library with newer library file...")
+ shutil.copy2(source, dest)
+ except Exception:
+ print("Failed to update WhatCanWePlay rust library. It will be re-attempted when next launched.")
+
+ from .lib.bin import whatcanweplay as wcwp
+except Exception as e:
+ print("Failed to load WhatCanWePlay rust library. Please go to \"lib/wcwp_rust\" and run \"cargo build --release\"")
+ print(e)
+ raise e
+print("WhatCanWePlay rust lib loaded (" + str(wcwp.__file__) + ")")
+
from flask import Flask, request, jsonify, Response, render_template, redirect, session, url_for, make_response
import requests
from urllib import parse
from werkzeug.exceptions import BadRequest
import json
-from .steam_utils import get_steam_user_info, get_steam_user_friend_list, get_owned_steam_games
-from .igdb_utils import get_steam_game_info
from requests import HTTPError
import secrets
from datetime import timezone, datetime, timedelta
from itsdangerous import URLSafeSerializer
from os import path
+import traceback
# Load config
def create_app():
@@ -33,25 +66,30 @@ def create_app():
config = json.load(open(path.join(root_path, "config.json"), "r"))
steam_key = config["steam-key"]
igdb_key = config["igdb-client-id"]
+ igdb_secret = config["igdb-secret"]
debug = config.get("debug", config.get("DEBUG", False))
enable_api_tests = config.get("enable-api-tests", debug)
cookie_max_age_dict = config.get("cookie-max-age", {})
- info_max_age_dict = config.get("info-max-age", {})
+ cache_max_age_dict = config.get("igdb-cache-max-age", config.get("igdb-cache-info-age", {}))
source_url = config.get("source-url", "")
contact_email = config["contact-email"]
privacy_email = config.get("privacy-email", contact_email)
connect_timeout = config.get("connect-timeout", 0.0)
+ commit_hash_filename = config.get("commit-hash-file", ".wcwp-commit-hash")
donate_url = config.get("donate-url", "")
if connect_timeout <= 0.0:
connect_timeout = None
read_timeout = config.get("read-timeout", 0.0)
if read_timeout <= 0.0:
read_timeout = None
+ cache_file = config.get("igdb-cache-file")
+ if not os.path.isabs(cache_file):
+ cache_file = os.path.join(root_path, cache_file)
# Create uWSGI callable
app = Flask(__name__)
app.debug = debug
- app.secret_key = config.get("secret-key", secrets.token_hex()) # If not set, cookies will be invalidated every time the app is reloaded
+ app.secret_key = config["secret-key"]
# Hide requests to /steam_login to prevent linking Steam ID to IP in logs
from werkzeug import serving
@@ -68,20 +106,61 @@ def create_app():
cookie_max_age = timedelta(**cookie_max_age_dict).total_seconds()
if cookie_max_age == 0:
cookie_max_age = None
- info_max_age = timedelta(**info_max_age_dict).total_seconds()
+
+ # Setup cache info max age
+ cache_max_age = 0.0
+ if cache_file:
+ cache_max_age = timedelta(**cache_max_age_dict).total_seconds()
+
+ print("cookies set to expire after %f seconds" % cookie_max_age)
+ print("cache set to expire after %f seconds" % cache_max_age)
+
+ def fetch_and_store_commit_hash():
+ f = open(commit_hash_filename, "w")
+ import subprocess
+ args = ['--git-dir=' + os.path.join(os.path.abspath(os.path.dirname(__file__)), ".git"), 'rev-parse', '--short', 'HEAD']
+ try:
+ try:
+ commit_hash = subprocess.check_output(['git'] + args).decode("utf-8").strip()
+ f.write(commit_hash)
+ f.close()
+ return commit_hash
+ except:
+ commit_hash = subprocess.check_output(['/usr/bin/git'] + args).decode("utf-8").strip()
+ f.write(commit_hash)
+ f.close()
+ return commit_hash
+ except:
+ return ""
+
+ @app.before_first_request
+ def before_first_request():
+ fetch_and_store_commit_hash()
- print("cookies set to expire after {} seconds".format(cookie_max_age))
- print("steam info set to refresh after {} seconds".format(info_max_age))
+ def get_commit_hash():
+ try:
+ f = open(commit_hash_filename, "r")
+ hash = f.read()
+ return hash
+ except Exception:
+ traceback.print_exc()
+ return ""
def basic_info_dict():
email_rev = contact_email.split("@")
- return {
+ basic_info = {
"contact_email_user_reversed": email_rev[0][::-1],
"contact_email_domain_reversed": email_rev[1][::-1],
"source_url": source_url,
"donate_url": donate_url
}
+ commit = get_commit_hash()
+ if commit:
+ basic_info["commit"] = commit
+
+ return basic_info
+
# Tries to fetch the Steam info cookie, returns an errcode and a dict
#
# Errcodes:
@@ -113,18 +192,20 @@ def create_app():
def refresh_steam_cookie(steamid: int, response):
if steamid <= 0:
- response.set_cookie("steam_info", "", secure=True)
- return {}
-
- info = get_steam_user_info(steam_key, [steamid], connect_timeout, read_timeout)
-
- if info["errcode"] != 0 or not info["users"].get(steamid, {}).get("exists", False):
- response.set_cookie("steam_info", "", secure=True)
+ response.set_cookie("steam_info", "", secure=True, httponly=True)
return {}
- info = info["users"][steamid]
- if info_max_age:
- info["expires"] = (datetime.now(timezone.utc) + timedelta(seconds=info_max_age)).timestamp()
+ info = {}
+ try:
+ info = wcwp.steam.get_steam_users_info(steam_key, [steamid])[0]
+ except IndexError:
+ response.set_cookie("steam_info", "", secure=True, httponly=True)
+ return {}
+ except Exception:
+ traceback.print_exc()
+ response.set_cookie("steam_info", "", secure=True, httponly=True)
+ return {}
+
ser = URLSafeSerializer(app.secret_key)
response.set_cookie(
"steam_info",
@@ -143,7 +224,7 @@ def create_app():
if errcode == 3:
steam_info = refresh_steam_cookie(steam_info.get("steam_id", -1), response)
elif errcode != 0:
- response.set_cookie("steam_info", "", secure=True)
+ response.set_cookie("steam_info", "", secure=True, httponly=True)
steam_info = {}
response.data = render_template("home.html", steam_info=steam_info, **basic_info_dict())
@@ -157,7 +238,7 @@ def create_app():
def steam_login():
if request.method == "POST":
steam_openid_url = 'https://steamcommunity.com/openid/login'
- return_url = url_for("steam_login", _external=True)
+ return_url = request.base_url
params = {
'openid.ns': "http://specs.openid.net/auth/2.0",
'openid.identity': "http://specs.openid.net/auth/2.0/identifier_select",
@@ -183,7 +264,7 @@ def create_app():
def steam_logout():
response = redirect(url_for("index"))
response.headers["X-Robots-Tag"] = "none"
- response.set_cookie("steam_info", "", secure=True)
+ response.set_cookie("steam_info", "", secure=True, httponly=True)
return response
def validate_steam_identity(params):
@@ -214,46 +295,196 @@ def create_app():
)
errcode, steam_info = fetch_steam_cookie(request)
if "steam_id" not in steam_info.keys():
- return ("Not signed in to Steam", 403)
+ return (
+ "Not signed in to Steam. Please refresh the page.",
+ 403
+ )
- friends_info = get_steam_user_friend_list(
- steam_key,
- steam_info["steam_id"],
- connect_timeout,
- read_timeout
- )
+ try:
+ friends_info = wcwp.steam.get_friend_list(
+ steam_key,
+ steam_info["steam_id"]
+ )
+
+ for user in friends_info:
+ if "steam_id" in user.keys():
+ user["steam_id"] = str(user["steam_id"])
+ user["exists"] = True
+
+ return jsonify(friends_info)
+ except wcwp.steam.BadWebkeyException:
+ traceback.print_exc()
+ return (
+ "Site has bad Steam API key. Please contact us about this error at " + contact_email,
+ 500
+ )
+ except wcwp.steam.ServerErrorException:
+ traceback.print_exc()
+ return (
+ "Steam had an internal server error. Please try again later.",
+ 500
+ )
+ except wcwp.steam.BadResponseException:
+ traceback.print_exc()
+ return (
+ "Steam returned an unparseable response. Please try again later.",
+ 500
+ )
+ except wcwp.steam.FriendListPrivateException:
+ traceback.print_exc()
+ return (
+ "WhatCanWePlay cannot retrieve your friend list. Please change your friend list visibility to public and refresh the page.",
+ 500
+ )
+ except Exception:
+ traceback.print_exc()
+ if debug:
+ return (
+ traceback.format_exc(),
+ 500
+ )
+ else:
+ traceback.print_exc()
+ return (
+ "An unknown error has occurred. Please try again later.",
+ 500
+ )
- errcode = friends_info.pop("errcode")
-
- if errcode == 1:
- return ("Site has bad Steam API key. Please contact us about this error at " + contact_email, 500)
- elif errcode == 2:
- return ("Steam took too long to respond. Please try again later.", 500)
- elif errcode == 3:
- return ("Steam took too long to transmit info. Please try again later.", 500)
- elif errcode == 4:
- return ("Your Friend List is not publicly accessible, and cannot be retrieved by WhatCanWePlay. Please set your Friend list visibility to Public and refresh the page.", 500)
- elif errcode == -1:
- return ("An unknown error occurred. Please try again later.", 500)
+ def refresh_igdb_token():
+ try:
+ token_path = path.join(root_path, "bearer-token.json")
+ token = wcwp.igdb.fetch_twitch_token(igdb_key, igdb_secret)
+ token["expiry"] = datetime.now(timezone.utc).timestamp() + token.get("expires_in", 0)
+ token.pop("expires_in")
+ json.dump(token, open(token_path, "w"))
+ return token.get("access_token", "")
+ except Exception:
+ traceback.print_exc()
+ return ""
+
+ def get_igdb_token():
+ try:
+ token_path = path.join(root_path, "bearer-token.json")
+ if path.exists(token_path):
+ token_file = open(token_path)
+ token = json.load(token_file)
+ token_file.close()
+ if datetime.now(timezone.utc).timestamp() >= token.get("expiry", 0):
+ return refresh_igdb_token()
+ else:
+ return token.get("access_token", "")
+ else:
+ return refresh_igdb_token()
+ except Exception:
+ traceback.print_exc()
+ return ""
+
+ cache_init_query = """
+ CREATE TABLE IF NOT EXISTS game (
+ steam_id INTEGER PRIMARY KEY,
+ igdb_id INTEGER,
+ name STRING,
+ supported_players INTEGER DEFAULT(0),
+ cover_id STRING,
+ has_multiplayer BOOLEAN,
+ expiry REAL DEFAULT(0.0)
+ );
+ """
+
+ CACHE_VERSION = 1
+
+ def initialize_cache():
+ import sqlite3
+ cache = sqlite3.connect(cache_file)
+ cache.execute(cache_init_query)
+ #cache.execute("PRAGMA user_version = ?;", [CACHE_VERSION]) # Doesn't work?
+ cache.execute("PRAGMA user_version = %d" % CACHE_VERSION)
+ return cache
+
+ def cache_is_correct_version(cache):
+ return cache.execute("PRAGMA user_version;").fetchone()[0] == CACHE_VERSION
+
+ def update_cached_games(game_info):
+ if not cache_file:
+ return
- friends_info = get_steam_user_info(steam_key, friends_info["friends"], connect_timeout, read_timeout)
-
- errcode = friends_info.pop("errcode")
- if errcode == 1:
- return ("Site has bad Steam API key. Please contact us about this error at " + contact_email, 500)
- elif errcode == 2:
- return ("Steam took too long to respond. Please try again later.", 500)
- elif errcode == 3:
- return ("Steam took too long to transmit info. Please try again later.", 500)
- elif errcode == -1:
- return ("An unknown error occurred. Please try again later.", 500)
+ try:
+ import sqlite3
+ cache = None
+
+ if os.path.exists(cache_file):
+ cache = sqlite3.connect(cache_file)
+ # Check if cache is correct version
+ if not cache_is_correct_version(cache):
+ # Cache is the wrong version, rebuild
+ print("Cache file is the wrong version! Rebuilding... ")
+ cache.close()
+ os.remove(cache_file)
+ cache = initialize_cache()
+ else:
+ cache = initialize_cache()
+
+ insert_info = [
+ [
+ game.get("steam_id"),
+ game.get("igdb_id"),
+ game.get("name"),
+ game.get("supported_players"),
+ game.get("cover_id"),
+ game.get("has_multiplayer"),
+ datetime.now(timezone.utc).timestamp() + cache_max_age
+ ] for game in game_info
+ ]
+
+ cache.executemany(
+ "INSERT OR REPLACE INTO game VALUES (?,?,?,?,?,?,?);",
+ insert_info
+ )
+
+ cache.commit()
+ cache.close()
+ except Exception:
+ print("FAILED TO UPDATE CACHE DB")
+ traceback.print_exc()
+ return
+
+ # returns [info of cached games], (set of uncached ids)
+ def get_cached_games(steam_ids):
+ if not cache_file:
+ return [], set(steam_ids)
+
+ game_info = []
+ uncached = set(steam_ids)
- for user in friends_info["users"].values():
- if "steam_id" in user.keys():
- user["steam_id"] = str(user["steam_id"])
- user["exists"] = True
+ try:
+ import sqlite3
+ cache = sqlite3.connect(cache_file)
+ cache.row_factory = sqlite3.Row
- return jsonify(friends_info["users"])
+ if not cache_is_correct_version(cache):
+ return [], set(steam_ids)
+
+ query_str = "SELECT * FROM game WHERE steam_id IN (%s)" % ("?" + (",?" * (len(steam_ids) - 1))) # Construct a query with arbitrary parameter length
+
+ cursor = cache.execute(
+ query_str,
+ steam_ids
+ )
+ for row in cursor.fetchall():
+ game = dict(row)
+ if datetime.now(timezone.utc).timestamp() < game.pop("expiry"):
+ # Info hasn't expired
+ game_info.append(game)
+ uncached.remove(game["steam_id"])
+
+ # Expired info gets updated during update_cached_games()
+ except Exception:
+ print("EXCEPTION THROWN WHILE QUERYING GAME CACHE!")
+ traceback.print_exc()
+ return [], set(steam_ids)
+
+ return game_info, uncached
+
# Errcodes
# -1: An error occurred with a message. Additional fields: "message"
@@ -316,90 +547,86 @@ def create_app():
json.dumps({"message": "Games intersection is capped at 10 users.", "errcode": -1}),
200
)
-
- free_games = bool(body.get("include_free_games", False))
- all_own = None
+ try:
+ token = get_igdb_token()
+ game_ids = wcwp.steam.intersect_owned_game_ids(steam_key, list(steamids))
- for steamid in steamids:
- user_owned_games = get_owned_steam_games(steam_key, steamid, free_games, connect_timeout, read_timeout)
- errcode = user_owned_games["errcode"]
+ fetched_game_count = 0
+ cached_game_count = 0
+ game_info = []
- if errcode == 1:
- return (
- json.dumps({"message": "Site has bad Steam API key. Please contact us about this error at {}.".format(contact_email), "errcode": -1}),
- 200
- )
- elif errcode == 2:
- return (
- json.dumps({"message": "Steam took too long to respond. Please try again later.", "errcode": -1}),
- 200
- )
- elif errcode == 3:
- return (
- json.dumps({"message": "Steam took too long to transmit info. Please try again later.", "errcode": -1}),
- 200
- )
- elif errcode == 4:
- return (
- json.dumps({"user": str(steamid), "errcode": 1}),
- 200
- )
- elif errcode == -1:
- return (
- json.dumps({"message": "An unknown error occurred. Please try again later.", "errcode": -1}),
- 200
- )
-
- games = user_owned_games.get("games")
- if len(games) == 0:
- return (
- json.dumps({"user": str(steamid), "errcode": 2}),
- 200
- )
-
- if not all_own:
- all_own = set(games)
- else:
- all_own = all_own & set(games)
-
- if len(all_own) == 0:
- break
-
- # Step two: Fetch the game info
- game_info = get_steam_game_info(igdb_key, all_own, connect_timeout, read_timeout)
+ if game_ids:
+ game_info, uncached_ids = get_cached_games(game_ids)
- errcode = game_info["errcode"]
+ cached_game_count = len(game_info)
- if errcode == 1:
+ if uncached_ids:
+ fetched_info, not_found = wcwp.igdb.get_steam_game_info(igdb_key, token, list(uncached_ids))
+
+ cache_info_update = fetched_info
+ if not_found:
+ for uncached_id in [id for id in not_found]:
+ cache_info_update.append({"steam_id": uncached_id}) # Cache empty data to prevent further IGDB fetch attempts
+ update_cached_games(cache_info_update) # TODO: Spin up separate process for caching?
+
+ game_info += fetched_info
+ fetched_game_count = len(fetched_info)
+
+ print("Intersection resulted in %d games (%d from cache, %d from IGDB)" % (len(game_info), cached_game_count, fetched_game_count))
+
+ return jsonify({
+ "message": "Intersected successfully",
+ "games": game_info,
+ "errcode": 0
+ })
+ except wcwp.steam.BadWebkeyException:
+ traceback.print_exc()
return (
- json.dumps({"message": "Site has bad IGDB API key. Please contact us about this error at {}.".format(contact_email), "errcode": -1}),
- 200
+ json.dumps({"message": "Site has bad Steam API key. Please contact us about this error at " + contact_email, "errcode": -1}),
+ 500
)
- elif errcode == 2:
+ except wcwp.steam.ServerErrorException:
+ traceback.print_exc()
return (
- json.dumps({"message": "IGDB took too long to respond. Please try again later.", "errcode": -1}),
- 200
+ json.dumps({"message": "Steam had an internal server error. Please try again later.", "errcode": -1}),
+ 500
)
- elif errcode == 3:
+ except wcwp.steam.BadResponseException:
+ traceback.print_exc()
return (
- json.dumps({"message": "IGDB took too long to transmit info. Please try again later.", "errcode": -1}),
- 200
+ json.dumps({"message": "Steam returned an unparseable response. Please try again later.", "errcode": -1}),
+ 500
)
- elif errcode == 4:
+ except wcwp.steam.GamesListPrivateException as e:
+ if debug:
+ print(e)
+ else:
+ print("Intersection interrupted due to private games list")
return (
- json.dumps({"message": "WhatCanWePlay failed to acquire an IGDB token. Please contact us about this error at {}.".format(contact_email), "errcode": -1})
+ json.dumps({"errcode": 1, "user": str(e.args[1])}),
+ 500
)
- elif errcode == -1:
+ except wcwp.steam.GamesListEmptyException as e:
+ if debug:
+ print(e)
+ else:
+ print("Intersection interrupted due to private games list")
return (
- json.dumps({"message": "An unknown error occurred. Please try again later.", "errcode": -1}),
- 200
+ json.dumps({"errcode": 2, "user": str(e.args[1])}),
+ 500
)
-
- return jsonify({
- "message": "Intersected successfully",
- "games": list(game_info.get("games", {}).values()),
- "errcode": 0
- })
+ except Exception:
+ traceback.print_exc()
+ if debug:
+ return (
+ json.dumps({"message": traceback.format_exc(), "errcode": -1}),
+ 500
+ )
+ else:
+ return (
+ json.dumps({"message": "An unknown error has occurred. Please try again later.", "errcode": -1}),
+ 500
+ )
return app
\ No newline at end of file
diff --git a/config_EXAMPLE.json b/config_EXAMPLE.json
index 5687a3b..f42aa04 100644
--- a/config_EXAMPLE.json
+++ b/config_EXAMPLE.json
@@ -4,22 +4,17 @@
"steam-key": "YOUR STEAM KEY HERE",
"igdb-client-id": "YOUR IGDB KEY HERE",
"igdb-secret": "YOUR IGDB SECRET HERE",
- "www-host": "yoururl.here",
"cookie-max-age": {
"days": 0,
"weeks": 0
},
- "info-max-age": {
- "hours": 0,
- "minutes": 0
- },
"source-url": "https://git.tgrc.dev/tgrcdev/whatcanweplay",
"contact-email": "contact@whatcanweplay.net",
"privacy-email": "privacy@whatcanweplay.net",
"connect-timeout": 8,
"read-timeout": 30,
"igdb-cache-file": "igdb-cache.sqlite",
- "igdb-cache-info-age": {
+ "igdb-cache-max-age": {
"weeks": 4
}
}
\ No newline at end of file
diff --git a/igdb_utils.py b/igdb_utils.py
deleted file mode 100644
index 3835ab8..0000000
--- a/igdb_utils.py
+++ /dev/null
@@ -1,251 +0,0 @@
-# This file is a part of WhatCanWePlay
-# Copyright (C) 2020 TGRCDev
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published
-# by the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-import requests
-import json
-import sqlite3
-from requests.exceptions import ConnectTimeout, ReadTimeout
-from typing import Dict, Collection, Any, Mapping, Optional
-from datetime import timedelta, datetime, timezone
-from os import path
-
-api_base = "https://api.igdb.com/v4/"
-
-root_path = path.dirname(__file__)
-config = json.load(open(path.join(root_path, "config.json"), "r"))
-debug = config.get("debug", config.get("DEBUG", False))
-db_filename = config.get("igdb-cache-filename", "igdb-cache.sqlite")
-if not path.isabs(db_filename):
- db_filename = path.join(root_path, db_filename)
-
-create_db_query = """
- CREATE TABLE IF NOT EXISTS game_info (
- steam_id INTEGER PRIMARY KEY,
- igdb_id INTEGER,
- name TEXT,
- cover_id TEXT,
- has_multiplayer BOOL,
- supported_players TEXT,
- time_cached REAL
- );
-"""
-
-info_age_dict = config.get("igdb-cache-info-age", {})
-info_age = timedelta(**info_age_dict).total_seconds()
-
-def fetch_and_store_token() -> str:
- r = requests.post(
- "https://id.twitch.tv/oauth2/token",
- json={
- "client_id": config["igdb-client-id"],
- "client_secret": config["igdb-secret"],
- "grant_type": "client_credentials"
- }
- )
-
- if debug:
- r.raise_for_status()
-
- try:
- token = r.json()
- token_dict = {
- "token": token["access_token"],
- "expire-time": datetime.now(timezone.utc).timestamp() + token["expires_in"],
- "token_type": token["token_type"]
- }
- token_file = open(path.join(root_path, "bearer-token.json"), "w")
- json.dump(token_dict, token_file)
- token_file.close()
- return token["access_token"]
- except Exception as e:
- print("Exception thrown while parsing token return")
- print(e)
- return ""
-
-def get_or_refresh_token() -> str:
- try:
- token_path = path.join(root_path, "bearer-token.json")
- if path.exists(token_path):
- token_file = open(token_path)
- token = json.load(token_file)
- token_file.close()
- if datetime.now(timezone.utc).timestamp() >= token.get("expire-time", 0):
- return fetch_and_store_token()
- else:
- return token.get("token", "")
- else:
- return fetch_and_store_token()
- except json.JSONDecodeError as e:
- print("Failed to decode token JSON.")
- print(e)
- return ""
- except Exception as e:
- print("Failed to fetch bearer token.")
- print(e)
- return ""
-
-def get_cached_games(appids: Collection[int]) -> Dict[int, Dict[str, Any]]:
- query = """
- SELECT steam_id, igdb_id, name, cover_id, has_multiplayer, supported_players
- FROM game_info
- WHERE time_cached > ? AND steam_id in ({})
- """.format(",".join(
- ["?" for _ in range(len(appids))]
- ))
- db_handle = sqlite3.connect(db_filename)
- db_handle.execute(create_db_query)
-
- cursor = db_handle.cursor()
- cursor.execute(query, [datetime.now(timezone.utc).timestamp() - info_age, *list(appids)])
- results = cursor.fetchall()
-
- return {
- game[0]: {
- "steam_id": game[0],
- "name": game[2],
- "cover_id": game[3],
- "has_multiplayer": game[4],
- "supported_players": game[5]
- } if game[1] else {}
- for game in results
- }
-
-def update_cached_games(game_info: Mapping[int, Mapping[str, Any]]):
- query = """
- INSERT OR REPLACE INTO game_info
- (steam_id, igdb_id, name, cover_id, has_multiplayer, supported_players, time_cached)
- VALUES (?,?,?,?,?,?,?)
- """
- db_handle = sqlite3.connect(db_filename)
- db_handle.execute(create_db_query)
- cursor = db_handle.cursor()
- now = datetime.now(timezone.utc).timestamp()
- cursor.execute("BEGIN TRANSACTION")
- cursor.executemany(query, [[
- game.get("steam_id"),
- game.get("igdb_id"),
- game.get("name"),
- game.get("cover_id"),
- game.get("has_multiplayer"),
- game.get("supported_players"),
- now] for game in game_info.values()]
- )
- cursor.execute("END TRANSACTION")
-
-def get_steam_game_info(webkey: str, appids: Collection[int], connect_timeout: Optional[float] = None, read_timeout: Optional[float] = None) -> Dict[int, Dict[str, Any]]:
- if len(appids) == 0:
- return {"errcode":0, "games":{}}
-
- appid_set = set(appids)
-
- cache_error = False
-
- try:
- cached_games = get_cached_games(appid_set)
- except Exception as e:
- print("An exception occurred while retrieving cached games: " + str(e))
- cached_games = {}
- cache_error = True
-
- if len(cached_games) == len(appid_set):
- return {"errcode": 0, "games": cached_games}
-
- uncached_ids = appid_set - set(cached_games.keys())
- uncached_ids_list = list(uncached_ids)
-
- token = get_or_refresh_token()
- if not token:
- return {"errcode": 4}
-
- games_dict = {}
- retrieved_games = 0
- while retrieved_games < len(uncached_ids_list):
- game_slice = uncached_ids_list[retrieved_games:500]
-
- retrieved_games += len(game_slice)
-
- try:
- r = requests.post(
- api_base + "external_games",
- data = "fields uid,game.name,game.game_modes,game.multiplayer_modes.onlinemax,game.multiplayer_modes.onlinecoopmax,game.cover.image_id; where uid = ({}) & category = 1; limit {};".format(",".join(map(str, game_slice)), len(game_slice)),
- headers = {
- "Client-ID": webkey,
- "Authorization": "Bearer " + token,
- "Accept": "application/json"
- },
- timeout = (connect_timeout, read_timeout)
- )
- except ConnectTimeout:
- return {"errcode": 2}
- except ReadTimeout:
- return {"errcode": 3}
- if r.status_code == 403:
- return {"errcode": 1}
- elif r.status_code != 200:
- if debug:
- r.raise_for_status()
- return {"errcode": -1}
-
- for game in r.json():
- steam_id = game.get("uid", None)
- if not steam_id:
- continue
- steam_id = int(steam_id)
- game = game.get("game", None)
- if not game:
- continue
- uncached_ids.discard(steam_id)
-
- game_modes = game.get("game_modes", [])
-
- is_multiplayer = (2 in game_modes or 5 in game_modes)
-
- maxplayers = -1
- if is_multiplayer:
- for mode in game.get("multiplayer_modes", []):
- maxplayers = max(max(mode.get("onlinemax", 1), mode.get("onlinecoopmax", 1)), maxplayers)
- else:
- maxplayers = 1
-
- games_dict[steam_id] = {
- "steam_id": steam_id,
- "igdb_id": game["id"],
- "name": game["name"],
- "cover_id": game.get("cover", {}).get("image_id", ""),
- "has_multiplayer": is_multiplayer,
- "supported_players": str(maxplayers) if maxplayers > 0 else "?"
- }
-
- # Any games that couldn't be retrieved probably dont exist. Store them so they don't trigger an IGDB fetch.
- for nonexist_id in uncached_ids:
- games_dict[nonexist_id] = {"steam_id": nonexist_id}
-
- if len(games_dict) == 0:
- return {"errcode":0, "games":cached_games}
-
- for id in games_dict.keys():
- cached_games[id] = games_dict[id]
-
- if not cache_error:
- try:
- update_cached_games(games_dict)
- except Exception as e:
- print("An exception occurred while updating cached games: " + str(e))
-
- return {
- "errcode": 0,
- "games": cached_games
- }
\ No newline at end of file
diff --git a/lib/wcwp_rust/Cargo.toml b/lib/wcwp_rust/Cargo.toml
new file mode 100644
index 0000000..f0460a9
--- /dev/null
+++ b/lib/wcwp_rust/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "whatcanweplay"
+version = "0.1.0"
+authors = ["TGRCDev "]
+edition = "2018"
+
+[lib]
+name = "whatcanweplay"
+crate-type = ["cdylib"]
+
+[dependencies]
+reqwest = { version = "0.10", features = ["json", "blocking"] }
+serde_json = "1.0"
+serde = { version = "1.0", features = ["derive", "std"] }
+pyo3 = { version = "0.12", features = ["extension-module"] }
\ No newline at end of file
diff --git a/lib/wcwp_rust/src/errors.rs b/lib/wcwp_rust/src/errors.rs
new file mode 100644
index 0000000..30bc8e9
--- /dev/null
+++ b/lib/wcwp_rust/src/errors.rs
@@ -0,0 +1,80 @@
+#[derive(Debug)]
+pub enum IGDBError {
+ UnknownError(String), // Unhandled error
+ ServerError, // IGDB had an internal error
+ BadResponse, // IGDB returned an unparseable response
+ BadClient, // The supplied client ID is wrong
+ BadSecret, // The supplied client secret is wrong
+ BadToken, // The supplied bearer token is wrong
+ BadAuth, // Some part of the supplied authentication is wrong
+}
+
+use std::fmt;
+
+impl fmt::Display for IGDBError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ IGDBError::ServerError => return write!(f, "IGDB had an internal server error"),
+ IGDBError::BadResponse => return write!(f, "IGDB returned an unparseable response"),
+ IGDBError::BadClient => return write!(f, "IGDB rejected the provided client ID"),
+ IGDBError::BadSecret => return write!(f, "IGDB rejected the provided client secret"),
+ IGDBError::BadToken => return write!(f, "IGDB rejected the provided bearer token"),
+ IGDBError::BadAuth => return write!(f, "IGDB rejected some part of the provided authentication"),
+ IGDBError::UnknownError(err_string) => return write!(f, "{}", &err_string),
+ _ => return write!(f, "IGDB API had an unknown error")
+ }
+ }
+}
+
+#[derive(Debug)]
+pub enum SteamError {
+ UnknownError(String), // Unhandled error
+ ServerError, // Steam had an internal error
+ BadResponse, // Steam returned an unparseable response
+ BadWebkey, // The supplied webkey is wrong
+ GamesListPrivate(u64), // The games list of the given steam id is unretrievable, probably due to game list privacy settings
+ GamesListEmpty(u64), // Intersection failed, the user with the given steam id had no games
+ FriendListPrivate, // The steam id passed to get_friend_list() has their friend list set to private
+}
+
+impl fmt::Display for SteamError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ SteamError::ServerError => return write!(f, "Steam had an internal server error"),
+ SteamError::BadResponse => return write!(f, "Steam returned an unparseable response"),
+ SteamError::BadWebkey => return write!(f, "Steam rejected the provided webkey"),
+ SteamError::UnknownError(err_string) => return write!(f, "{}", &err_string),
+ SteamError::GamesListPrivate(steamid) => return write!(f, "The user with Steam ID {} has their games list set to private", steamid),
+ SteamError::GamesListEmpty(steamid) => return write!(f, "The user with the Steam ID {} has no games to intersect", steamid),
+ SteamError::FriendListPrivate => return write!(f, "The given user has their friend list set to private"),
+ _ => return write!(f, "Steam had an unknown error")
+ }
+ }
+}
+
+#[derive(Debug)]
+pub enum WCWPError {
+ SteamError(SteamError), // Steam API Error
+ IGDBError(IGDBError), // IGDB API Error
+}
+
+impl fmt::Display for WCWPError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ WCWPError::SteamError(e) => return e.fmt(f),
+ WCWPError::IGDBError(e) => return e.fmt(f),
+ }
+ }
+}
+
+impl From for WCWPError {
+ fn from(e: SteamError) -> Self {
+ return WCWPError::SteamError(e);
+ }
+}
+
+impl From for WCWPError {
+ fn from(e: IGDBError) -> Self {
+ return WCWPError::IGDBError(e);
+ }
+}
\ No newline at end of file
diff --git a/lib/wcwp_rust/src/igdb.rs b/lib/wcwp_rust/src/igdb.rs
new file mode 100644
index 0000000..8efbdc8
--- /dev/null
+++ b/lib/wcwp_rust/src/igdb.rs
@@ -0,0 +1,306 @@
+use reqwest;
+use serde::{Deserialize, Serialize};
+use serde_json;
+use std::collections::HashSet;
+use std::fmt::{Debug, Write};
+use crate::errors::IGDBError;
+use reqwest::StatusCode;
+use serde_json::Value;
+use std::convert::{TryFrom, TryInto};
+use std::cmp::{max, min};
+
+const API_URL : &str = "https://api.igdb.com/v4/";
+const TOKEN_URL : &str = "https://id.twitch.tv/oauth2/token";
+
+#[derive(Serialize, Deserialize, Debug)]
+/// Struct for retrieving a bearer token from the Twitch Developer API
+pub struct Token {
+ pub access_token: String,
+ pub expires_in: u64
+}
+
+#[derive(Debug, Serialize)]
+/// Struct for games described by IGDB
+pub struct GameInfo {
+ pub steam_id: u64,
+ pub igdb_id: u64,
+ pub name: String,
+ pub supported_players: u8,
+
+ /// Cover image url
+ ///
+ /// Usage: `https://images.igdb.com/igdb/image/upload/t_cover_small/.jpg`
+ pub cover_id: String,
+
+ pub has_multiplayer: bool,
+}
+
+/// Takes a serde_json Value and tries to get an i64
+/// either by parsing an i64 directly, or by parsing
+/// a stringified i64.
+fn parse_u8_from_value(value: &Value) -> Option {
+ let num = parse_u64_from_value(value);
+ match num {
+ Some(num) =>
+ return num.try_into().ok(),
+ None =>
+ return None
+ }
+}
+
+/// Takes a serde_json Value and tries to get an i64
+/// either by parsing an u64 directly, or by parsing
+/// a stringified u64.
+fn parse_u64_from_value(value: &Value) -> Option {
+ match value {
+ Value::Number(num) => {
+ return num.as_u64();
+ },
+ Value::String(string) => {
+ return string.parse().ok();
+ },
+ _ => return None
+ }
+}
+
+impl TryFrom<&Value> for GameInfo {
+ type Error = IGDBError;
+
+ /// Used to extract game information from IGDB query results
+ fn try_from(value: &Value) -> Result {
+ let steam_id : u64;
+ match parse_u64_from_value(&value["uid"]) {
+ Some(id) => steam_id = id,
+ None => return Err(IGDBError::BadResponse)
+ }
+
+ let game = &value["game"];
+ if !game.is_object()
+ {
+ return Err(IGDBError::BadResponse);
+ }
+
+ let igdb_id : u64;
+ match parse_u64_from_value(&game["id"]) {
+ Some(id) => igdb_id = id,
+ None => return Err(IGDBError::BadResponse)
+ }
+
+ let name : &str;
+ match game["name"].as_str() {
+ Some(val) => name = val,
+ None => return Err(IGDBError::BadResponse),
+ }
+
+ let cover_id : &str;
+ match game["cover"]["image_id"].as_str() {
+ Some(val) => cover_id = val,
+ None => cover_id = "",
+ }
+
+ // Figure out multiplayer and whatnot
+
+ let game_modes = &game["game_modes"];
+ let has_multiplayer: bool;
+ let supported_players : u8;
+
+ if game_modes.is_array() {
+ let gamemode_arr = game_modes.as_array().unwrap();
+
+ has_multiplayer = gamemode_arr.iter().any(|x| x == 5 || x == 2); // TODO: This isn't really needed with game["multiplayer_modes"], replace it
+ if has_multiplayer
+ {
+ let multiplayer_modes = &game["multiplayer_modes"];
+ if let Some(multimodes) = multiplayer_modes.as_array() {
+ let mut mostplayers = 0;
+ for mode in multimodes.iter() {
+ let onlinemax : u8 = parse_u8_from_value(&mode["onlinemax"]).unwrap_or(0);
+ let coopmax : u8 = parse_u8_from_value(&mode["onlinecoopmax"]).unwrap_or(0);
+ mostplayers = max(onlinemax, coopmax);
+ }
+ supported_players = max(min(255u8, mostplayers), 0u8).try_into().unwrap();
+ }
+ else
+ {
+ supported_players = 0; // Displayed as ? for number of supported players
+ }
+ }
+ else
+ {
+ supported_players = 1;
+ }
+ }
+ else
+ {
+ has_multiplayer = false;
+ supported_players = 1;
+ }
+
+ return Ok(GameInfo {
+ steam_id,
+ igdb_id,
+ name: name.to_string(),
+ cover_id: cover_id.to_string(),
+ supported_players,
+ has_multiplayer,
+ });
+ }
+}
+
+/// Fetches a Twitch app bearer token for use with the IGDB API.
+///
+/// # Errors
+///
+/// `IGDBError::BadClient` is returned if the client ID is invalid.
+///
+/// `IGDBError::BadSecret` is returned if the client secret is invalid.
+///
+/// `IGDBError::ServerError` is returned if Twitch returned an error code.
+///
+/// `IGDBError::UnknownError` is returned for any unexpected status codes, or if Twitch was unreachable.
+pub fn get_twitch_token(client_id: &str, secret: &str) -> Result {
+ let client = reqwest::blocking::Client::new();
+
+ let res = client.post(TOKEN_URL)
+ .query(&[
+ ("client_id", client_id),
+ ("client_secret", secret),
+ ("grant_type", "client_credentials")
+ ]).send();
+
+ if let Err(e) = res {
+ return Err(IGDBError::UnknownError(e.to_string()));
+ }
+
+ let res = res.unwrap();
+
+ if !res.status().is_success() {
+ match res.status() {
+ StatusCode::BAD_REQUEST =>
+ return Err(IGDBError::BadClient),
+ StatusCode::FORBIDDEN =>
+ return Err(IGDBError::BadSecret),
+ StatusCode::INTERNAL_SERVER_ERROR | StatusCode::BAD_GATEWAY =>
+ return Err(IGDBError::ServerError),
+ _ =>
+ return Err(IGDBError::UnknownError(res.text().unwrap_or("Unknown error".to_string())))
+ }
+ }
+
+ let response = res.text().expect("Reqwest errored while getting response text");
+ let t: Token = serde_json::from_str(&response).expect(&format!("Serde failed to load JSON response from IGDB (response: {})", response));
+
+ return Ok(t);
+}
+
+/// Fetch the info for the provided set of steam app IDs
+///
+/// Returns a tuple of two `Vec`s, a `Vec` containing the info of found games, and a `Vec` of app IDs not found.
+/// If `appids` is empty, returns two empty `Vec`s.
+///
+/// # Errors
+///
+/// `IGDBError::BadAuth` is returned if either the `client_id` or `bearer_token` are invalid.
+///
+/// `IGDBError::ServerError` is returned if IGDB was unable to process the request.
+///
+///`IGDBError::UnknownError` is returned for any unexpected status codes, or if IGDB was unreachable.
+pub fn get_steam_game_info(client_id: &str, bearer_token: &str, appids: &[u64]) -> Result<(Vec, HashSet), IGDBError> {
+ if appids.is_empty()
+ {
+ return Ok((Vec::new(), HashSet::new()));
+ }
+
+ let client = reqwest::blocking::Client::new();
+ let mut games_info : Vec = Vec::new();
+ let mut not_found : HashSet = HashSet::with_capacity(appids.len());
+
+ for current_slice in appids.chunks(500)
+ {
+ let mut id_str = current_slice[0].to_string();
+
+ for n in current_slice[1..].iter()
+ {
+ write!(&mut id_str, ",{}", n).unwrap();
+ }
+
+ let response = client.post(&format!("{}{}", API_URL, "external_games"))
+ .header("Client-ID", client_id)
+ .header("Authorization", format!("Bearer {}", bearer_token))
+ .header("Accept", "application/json")
+ .body(format!(
+ "fields uid,game.name,game.game_modes,game.multiplayer_modes.onlinemax,
+ game.multiplayer_modes.onlinecoopmax,game.cover.image_id;
+ where uid = ({}) & category = 1; limit {};",
+ id_str, current_slice.len()
+ )).send();
+
+ if let Err(e) = response {
+ return Err(IGDBError::UnknownError(e.to_string()));
+ }
+
+ let response = response.unwrap();
+
+ if !response.status().is_success() {
+ match response.status() {
+ StatusCode::UNAUTHORIZED => {
+ return Err(IGDBError::BadClient);
+ },
+ StatusCode::FORBIDDEN => {
+ return Err(IGDBError::BadAuth);
+ },
+ StatusCode::INTERNAL_SERVER_ERROR | StatusCode::BAD_GATEWAY =>
+ return Err(IGDBError::ServerError),
+ _ =>
+ return Err(IGDBError::UnknownError(
+ response.text().unwrap_or("Unknown error".to_string()))
+ ),
+ }
+ }
+
+ for id in current_slice.iter()
+ { not_found.insert(*id); }
+
+ if let Ok(games_json) = response.json::() {
+ if let Some(games) = games_json.as_array() {
+ for game in games.iter() {
+ if let Ok(game_info) = GameInfo::try_from(game) {
+ not_found.remove(&game_info.steam_id);
+ games_info.push(game_info);
+ }
+ }
+ }
+ }
+ }
+
+ return Ok((games_info, not_found));
+}
+
+#[test]
+fn unwrap_token() {
+ let test_token = "
+ {
+ \"access_token\": \"thisisnotarealtokenbutpretenditis\",
+ \"refresh_token\": \"\",
+ \"expires_in\": 3600,
+ \"scope\": [],
+ \"token_type\": \"bearer\"
+ }";
+
+ let t: Token = serde_json::from_str(test_token).unwrap();
+
+ println!("{:#?}", t);
+}
+
+#[test]
+fn unwrap_game() {
+ let games = "[{\"uid\": \"1\", \"game\":{\"id\": 5, \"name\":\"Test\"}}]";
+
+ let games_json: Value = serde_json::from_str(games).unwrap();
+
+ for game in games_json.as_array().unwrap()
+ {
+ let g: GameInfo = game.try_into().unwrap();
+ println!("{:#?}", g);
+ }
+}
\ No newline at end of file
diff --git a/lib/wcwp_rust/src/lib.rs b/lib/wcwp_rust/src/lib.rs
new file mode 100644
index 0000000..4306f21
--- /dev/null
+++ b/lib/wcwp_rust/src/lib.rs
@@ -0,0 +1,5 @@
+pub mod igdb;
+pub mod wcwp;
+pub mod steam;
+pub mod errors;
+mod python;
\ No newline at end of file
diff --git a/lib/wcwp_rust/src/python.rs b/lib/wcwp_rust/src/python.rs
new file mode 100644
index 0000000..6d530a7
--- /dev/null
+++ b/lib/wcwp_rust/src/python.rs
@@ -0,0 +1,311 @@
+use pyo3::prelude::*;
+use pyo3::wrap_pyfunction;
+use pyo3::types::{PyTuple, PyList};
+
+pub mod conversions {
+ use pyo3::prelude::*;
+ use pyo3::types::PyDict;
+ use pyo3::conversion::ToPyObject;
+
+ use crate::igdb;
+ impl IntoPy for igdb::Token {
+ fn into_py(self, py: Python) -> PyObject {
+ let obj = PyDict::new(py);
+ obj.set_item("access_token", self.access_token).unwrap();
+ obj.set_item("expires_in", self.expires_in).unwrap();
+ return obj.into();
+ }
+ }
+
+ impl IntoPy for igdb::GameInfo {
+ fn into_py(self, py: Python) -> PyObject {
+ let obj = PyDict::new(py);
+ obj.set_item("steam_id", self.steam_id).unwrap();
+ obj.set_item("igdb_id", self.igdb_id).unwrap();
+ obj.set_item("name", self.name).unwrap();
+ obj.set_item("supported_players", self.supported_players).unwrap();
+ obj.set_item("cover_id", self.cover_id).unwrap();
+ obj.set_item("has_multiplayer", self.has_multiplayer).unwrap();
+
+ return obj.into();
+ }
+ }
+
+ impl ToPyObject for igdb::GameInfo {
+ fn to_object(&self, py: Python) -> PyObject {
+ let obj = PyDict::new(py);
+ obj.set_item("steam_id", self.steam_id).unwrap();
+ obj.set_item("igdb_id", self.igdb_id).unwrap();
+ obj.set_item("name", self.name.clone()).unwrap();
+ obj.set_item("supported_players", self.supported_players).unwrap();
+ obj.set_item("cover_id", self.cover_id.clone()).unwrap();
+ obj.set_item("has_multiplayer", self.has_multiplayer).unwrap();
+
+ return obj.into();
+ }
+ }
+
+ use crate::steam;
+ impl IntoPy for steam::SteamUser {
+ fn into_py(self, py: Python) -> PyObject {
+ let obj = PyDict::new(py);
+ obj.set_item("steam_id", self.steam_id).unwrap();
+ obj.set_item("screen_name", self.screen_name).unwrap();
+ obj.set_item("avatar_thumb", self.avatar_thumb).unwrap();
+ obj.set_item("avatar", self.avatar).unwrap();
+ obj.set_item("visibility", self.visibility).unwrap();
+ obj.set_item("online", self.online).unwrap();
+
+ return obj.into();
+ }
+ }
+
+ impl ToPyObject for steam::SteamUser {
+ fn to_object(&self, py: Python) -> PyObject {
+ let obj = PyDict::new(py);
+ obj.set_item("steam_id", self.steam_id).unwrap();
+ obj.set_item("screen_name", self.screen_name.clone()).unwrap();
+ obj.set_item("avatar_thumb", self.avatar_thumb.clone()).unwrap();
+ obj.set_item("avatar", self.avatar.clone()).unwrap();
+ obj.set_item("visibility", self.visibility).unwrap();
+ obj.set_item("online", self.online).unwrap();
+
+ return obj.into();
+ }
+ }
+}
+
+use crate::igdb;
+
+pub mod igdb_exceptions {
+ use pyo3::create_exception;
+ use pyo3::exceptions::{PyException};
+ use pyo3::PyErr;
+
+ use crate::errors::IGDBError;
+
+ create_exception!(igdb, IGDBException, PyException);
+
+ create_exception!(igdb, UnknownErrorException, IGDBException);
+ create_exception!(igdb, ServerErrorException, IGDBException);
+ create_exception!(igdb, BadClientException, IGDBException);
+ create_exception!(igdb, BadResponseException, IGDBException);
+ create_exception!(igdb, BadSecretException, IGDBException);
+ create_exception!(igdb, BadTokenException, IGDBException);
+ create_exception!(igdb, BadAuthException, IGDBException);
+
+ impl From for PyErr {
+ fn from(e: IGDBError) -> PyErr {
+ match e {
+ IGDBError::ServerError => ServerErrorException::new_err(e.to_string()),
+ IGDBError::BadResponse => BadResponseException::new_err(e.to_string()),
+ IGDBError::BadClient => BadClientException::new_err(e.to_string()),
+ IGDBError::BadSecret => BadSecretException::new_err(e.to_string()),
+ IGDBError::BadToken => BadTokenException::new_err(e.to_string()),
+ IGDBError::BadAuth => BadAuthException::new_err(e.to_string()),
+ _ => UnknownErrorException::new_err(e.to_string()),
+ }
+ }
+ }
+}
+
+#[pyfunction]
+pub fn fetch_twitch_token(_py: Python, client_id: &str, secret: &str) -> PyResult {
+ let token = igdb::get_twitch_token(client_id, secret);
+ match token {
+ Ok(token) => return Ok(token),
+ Err(e) => return Err(e.into())
+ }
+}
+
+#[pyfunction]
+pub fn get_steam_game_info(_py: Python, client_id: &str, bearer_token: &str, appids: Vec) -> PyResult {
+ let result = igdb::get_steam_game_info(client_id, bearer_token, &appids);
+
+ match result {
+ Err(e) => {
+ return Err(e.into());
+ },
+ Ok((games, not_found)) => {
+ let games = PyList::new(_py, games);
+ let not_found = PyList::new(_py, ¬_found);
+ let tuple : Vec = vec!(games.into(), not_found.into());
+ return Ok(PyTuple::new(_py, tuple).into());
+ }
+ }
+}
+
+macro_rules! expose_exception {
+ ($py:expr, $m:expr, $exc:ty) => {
+ $m.add(stringify!($exc), $py.get_type::<$exc>());
+ }
+}
+
+fn igdb_mod(py: &Python, m: &PyModule) -> PyResult<()> {
+ m.add_function(wrap_pyfunction!(fetch_twitch_token, m)?)?;
+ m.add_function(wrap_pyfunction!(get_steam_game_info, m)?)?;
+
+ use igdb_exceptions::*;
+
+ // Exceptions
+ expose_exception!(py, m, IGDBException)?;
+ expose_exception!(py, m, UnknownErrorException)?;
+ expose_exception!(py, m, ServerErrorException)?;
+ expose_exception!(py, m, BadClientException)?;
+ expose_exception!(py, m, BadResponseException)?;
+ expose_exception!(py, m, BadSecretException)?;
+ expose_exception!(py, m, BadTokenException)?;
+ expose_exception!(py, m, BadAuthException)?;
+
+ return Ok(());
+}
+
+use crate::steam;
+
+pub mod steam_exceptions {
+ use pyo3::create_exception;
+ use pyo3::exceptions::{PyException};
+ use pyo3::PyErr;
+
+ use crate::errors::SteamError;
+
+ create_exception!(steam, SteamException, PyException);
+
+ create_exception!(steam, UnknownErrorException, SteamException);
+ create_exception!(steam, ServerErrorException, SteamException);
+ create_exception!(steam, BadResponseException, SteamException);
+ create_exception!(steam, BadWebkeyException, SteamException);
+ create_exception!(steam, GamesListPrivateException, SteamException);
+ create_exception!(steam, GamesListEmptyException, SteamException);
+ create_exception!(steam, FriendListPrivateException, SteamException);
+
+ impl From for PyErr {
+ fn from(e: SteamError) -> PyErr {
+ match e {
+ SteamError::ServerError => ServerErrorException::new_err(e.to_string()),
+ SteamError::BadResponse => BadResponseException::new_err(e.to_string()),
+ SteamError::BadWebkey => BadWebkeyException::new_err(e.to_string()),
+ SteamError::FriendListPrivate => FriendListPrivateException::new_err(e.to_string()),
+ SteamError::GamesListPrivate(steamid) => GamesListPrivateException::new_err((e.to_string(), steamid)),
+ SteamError::GamesListEmpty(steamid) => GamesListEmptyException::new_err((e.to_string(), steamid)),
+ _ => UnknownErrorException::new_err(e.to_string()),
+ }
+ }
+ }
+}
+
+#[pyfunction]
+pub fn get_steam_users_info(_py: Python, webkey: &str, steamids: Vec) -> PyResult {
+ let result = steam::get_steam_users_info(webkey, &steamids);
+
+ match result {
+ Err(e) => {
+ return Err(e.into());
+ },
+ Ok(users) => {
+ return Ok(PyList::new(_py, users).into());
+ },
+ }
+}
+
+#[pyfunction]
+pub fn get_owned_steam_games(_py: Python, webkey: &str, steamid: u64) -> PyResult {
+ let result = steam::get_owned_steam_games(webkey, steamid);
+
+ match result {
+ Err(e) => {
+ return Err(e.into());
+ },
+ Ok(game_ids) => {
+ return Ok(PyList::new(_py, game_ids).into());
+ }
+ }
+}
+
+#[pyfunction]
+pub fn get_friend_list(_py: Python, webkey: &str, steamid: u64) -> PyResult {
+ let result = steam::get_friend_list(webkey, steamid);
+
+ match result {
+ Err(e) => {
+ return Err(e.into());
+ },
+ Ok(friends) => {
+ return Ok(PyList::new(_py, friends).into());
+ }
+ }
+}
+
+#[pyfunction]
+pub fn intersect_owned_game_ids(_py: Python, webkey: &str, steamids: Vec) -> PyResult {
+ let result = steam::intersect_owned_game_ids(webkey, &steamids);
+
+ match result {
+ Err(e) => {
+ return Err(e.into());
+ },
+ Ok(appids) => {
+ return Ok(PyList::new(_py, appids).into());
+ }
+ }
+}
+
+fn steam_mod(py: &Python, m: &PyModule) -> PyResult<()> {
+ m.add_function(wrap_pyfunction!(get_steam_users_info, m)?)?;
+ m.add_function(wrap_pyfunction!(get_owned_steam_games, m)?)?;
+ m.add_function(wrap_pyfunction!(get_friend_list, m)?)?;
+ m.add_function(wrap_pyfunction!(intersect_owned_game_ids, m)?)?;
+
+ use steam_exceptions::*;
+
+ // Exceptions
+ expose_exception!(py, m, SteamException)?;
+ expose_exception!(py, m, UnknownErrorException)?;
+ expose_exception!(py, m, ServerErrorException)?;
+ expose_exception!(py, m, BadResponseException)?;
+ expose_exception!(py, m, BadWebkeyException)?;
+ expose_exception!(py, m, FriendListPrivateException)?;
+ expose_exception!(py, m, GamesListEmptyException)?;
+ expose_exception!(py, m, GamesListPrivateException)?;
+
+ return Ok(());
+}
+
+use crate::wcwp;
+
+pub mod wcwp_exceptions {
+ use pyo3::PyErr;
+
+ use crate::errors::WCWPError;
+
+ impl From for PyErr {
+ fn from(e: WCWPError) -> PyErr {
+ match e {
+ WCWPError::IGDBError(e) => return e.into(),
+ WCWPError::SteamError(e) => return e.into(),
+ }
+ }
+ }
+}
+
+#[pyfunction]
+pub fn intersect_owned_games(_py: Python, webkey: &str, igdb_id: &str, igdb_token: &str, steamids: Vec) -> PyResult {
+ let result = wcwp::intersect_owned_games(webkey, igdb_id, igdb_token, &steamids)?;
+
+ return Ok(PyList::new(_py, result).into());
+}
+
+#[pymodule]
+fn whatcanweplay(py: Python, m: &PyModule) -> PyResult<()> {
+ let submod = PyModule::new(py, "igdb")?;
+ igdb_mod(&py, submod)?;
+ m.add_submodule(submod)?;
+
+ let submod = PyModule::new(py, "steam")?;
+ steam_mod(&py, submod)?;
+ m.add_submodule(submod)?;
+
+ m.add_function(wrap_pyfunction!(intersect_owned_games, m)?)?;
+
+ return Ok(());
+}
\ No newline at end of file
diff --git a/lib/wcwp_rust/src/steam.rs b/lib/wcwp_rust/src/steam.rs
new file mode 100644
index 0000000..03931eb
--- /dev/null
+++ b/lib/wcwp_rust/src/steam.rs
@@ -0,0 +1,303 @@
+use serde::{Serialize, Deserialize};
+
+const API_URL: &str = "https://api.steampowered.com/";
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SteamUser {
+ #[serde(rename(deserialize = "steamid"))]
+ #[serde(deserialize_with = "u64_string_parse")]
+ pub steam_id: u64,
+
+ #[serde(rename(deserialize = "personaname"))]
+ pub screen_name: String,
+
+ #[serde(rename(deserialize = "avatar"))]
+ pub avatar_thumb: String,
+
+ #[serde(rename(deserialize = "avatarmedium"))]
+ pub avatar: String,
+
+ #[serde(rename(deserialize = "communityvisibilitystate"))]
+ pub visibility: i8,
+
+ #[serde(rename(deserialize = "personastate"))]
+ #[serde(deserialize_with = "bool_from_int")]
+ pub online: bool
+}
+
+use crate::errors::SteamError;
+
+use reqwest;
+use reqwest::{StatusCode, Url};
+
+use std::fmt::Write;
+use std::collections::HashSet;
+
+use serde::de::{self, Deserializer};
+
+fn bool_from_int<'de, D>(deserializer: D) -> Result
+where
+ D: Deserializer<'de>,
+{
+ match u8::deserialize(deserializer)? {
+ 0 => Ok(false),
+ _ => Ok(true),
+ }
+}
+
+fn u64_string_parse<'de, D>(deserializer: D) -> Result
+where
+ D: Deserializer<'de>,
+{
+ let val : serde_json::Value = Deserialize::deserialize(deserializer)?;
+ match val {
+ serde_json::Value::Number(num) => {
+ if let Some(num) = num.as_u64() {
+ return Ok(num);
+ }
+ },
+ serde_json::Value::String(string) => {
+ if let Ok(num) = string.parse() {
+ return Ok(num);
+ }
+ }
+ _ => {}
+ }
+
+ return Err(de::Error::custom(&"expected u64 or stringified u64"));
+}
+
+pub fn get_steam_users_info(webkey: &str, steamids: &[u64]) -> Result, SteamError> {
+ if steamids.is_empty() {
+ return Ok(Vec::new());
+ }
+ let client = reqwest::blocking::Client::new();
+
+ let mut id_str = steamids[0].to_string();
+
+ for n in steamids[1..].iter()
+ {
+ write!(&mut id_str, ",{}", n).unwrap();
+ }
+
+ let base_url = Url::parse(API_URL).unwrap();
+
+ let response = client.get(base_url.join("ISteamUser/GetPlayerSummaries/v2/").unwrap())
+ .query(&[
+ ("key", webkey),
+ ("format", "json"),
+ ("steamids", &id_str)
+ ])
+ .send();
+
+ if let Err(e) = response {
+ return Err(SteamError::UnknownError(e.to_string()));
+ }
+
+ let response = response.unwrap();
+
+ if !response.status().is_success() {
+ match response.status() {
+ StatusCode::BAD_GATEWAY | StatusCode::INTERNAL_SERVER_ERROR =>
+ return Err(SteamError::ServerError),
+ StatusCode::FORBIDDEN =>
+ return Err(SteamError::BadWebkey),
+ _ =>
+ return Err(SteamError::UnknownError(response.text().unwrap_or("Unknown error".to_string())))
+ }
+ }
+
+ let response_json = response.json();
+ if let Err(_) = response_json {
+ return Err(SteamError::BadResponse); // TODO: Turn BadResponse into BadResponse(String, String) to hold response and error text?
+ }
+ let mut response: serde_json::Value = response_json.unwrap();
+ let players = &mut response["response"]["players"];
+
+ let mut users = Vec::new();
+
+ if players.is_array() {
+ for player_json in players.as_array_mut().unwrap().iter_mut() {
+ let user_info: Option = serde_json::from_value(player_json.take()).unwrap();
+
+ if let Some(user) = user_info {
+ users.push(user);
+ }
+ }
+ }
+
+ return Ok(users);
+}
+
+///
+/// # Errors
+///
+/// `SteamError::BadWebkey` is returned if the provided webkey is invalid
+///
+/// `SteamError::ServerError` is returned if the given steamid does not exist, or if the server had an error processing the request.
+/// (We can't differentiate between the two, they're both returned as 500 status code)
+pub fn get_owned_steam_games(webkey: &str, steamid: u64) -> Result, SteamError> {
+ let base_url = Url::parse(API_URL).unwrap();
+ let client = reqwest::blocking::Client::new();
+
+ let response = client.get(base_url.join("IPlayerService/GetOwnedGames/v0001/").unwrap())
+ .query(&[
+ ("key", webkey),
+ ("steamid", &steamid.to_string()),
+ ("include_appinfo", "false"),
+ ("include_played_free_games", "true"),
+ ("format", "json")
+ ]).send();
+
+ if let Err(e) = response {
+ return Err(SteamError::UnknownError(e.to_string()));
+ }
+
+ let response = response.unwrap();
+
+ if !response.status().is_success() {
+ match response.status() {
+ StatusCode::UNAUTHORIZED =>
+ return Err(SteamError::BadWebkey),
+ StatusCode::INTERNAL_SERVER_ERROR =>
+ return Err(SteamError::ServerError),
+ _ =>
+ return Err(SteamError::UnknownError(response.text().unwrap_or("Unknown error".to_string()))),
+ }
+ }
+
+ let response_json = response.json();
+ if let Err(_) = response_json {
+ return Err(SteamError::BadResponse);
+ }
+
+ let response_json: serde_json::Value = response_json.unwrap();
+
+ let mut app_ids = HashSet::new();
+
+ let games_arr = &response_json["response"]["games"];
+ if let Some(games) = games_arr.as_array() {
+ for game in games {
+ let id = &game["appid"];
+ if let Some(id) = id.as_u64() {
+ app_ids.insert(id);
+ }
+ }
+ }
+
+ return Ok(app_ids);
+}
+
+pub fn get_friend_list(webkey: &str, steamid: u64) -> Result, SteamError>
+{
+ let base_url = Url::parse(API_URL).unwrap();
+ let client = reqwest::blocking::Client::new();
+ let response = client.get(base_url.join("ISteamUser/GetFriendList/v0001/").unwrap())
+ .query(&[
+ ("key", webkey),
+ ("steamid", &steamid.to_string()),
+ ("relationship", "friend"),
+ ("format", "json")
+ ]).send();
+
+ if let Err(e) = response {
+ return Err(SteamError::UnknownError(e.to_string()));
+ }
+
+ let response = response.unwrap();
+
+ if !response.status().is_success() {
+ match response.status() {
+ StatusCode::UNAUTHORIZED =>
+ return Err(SteamError::BadWebkey),
+ StatusCode::INTERNAL_SERVER_ERROR =>
+ return Err(SteamError::ServerError),
+ _ =>
+ return Err(SteamError::UnknownError(response.text().unwrap_or("Unknown error".to_string()))),
+ }
+ }
+
+ let response_json = response.json();
+ if let Err(_) = response_json {
+ return Err(SteamError::BadResponse);
+ }
+
+ let response_json: serde_json::Value = response_json.unwrap();
+
+ let friendslist = &response_json["friendslist"];
+ if friendslist.is_null()
+ {
+ return Err(SteamError::FriendListPrivate);
+ }
+
+ let friendslist = &friendslist["friends"];
+
+ let mut user_ids = Vec::new();
+
+ if let Some(friendslist) = friendslist.as_array()
+ {
+ for friend in friendslist
+ {
+ let id_val = &friend["steamid"];
+ match id_val {
+ serde_json::Value::Number(num) => {
+ if let Some(num) = num.as_u64()
+ {
+ user_ids.push(num);
+ }
+ },
+ serde_json::Value::String(numstr) => {
+ if let Ok(num) = numstr.parse() {
+ user_ids.push(num);
+ }
+ },
+ _ => {},
+ }
+ }
+ }
+ else
+ {
+ return Err(SteamError::FriendListPrivate);
+ }
+
+ if user_ids.is_empty() {
+ return Ok(Vec::new())
+ }
+
+ let friends_info = get_steam_users_info(webkey, &user_ids)?;
+
+ return Ok(friends_info);
+}
+
+pub fn intersect_owned_game_ids(webkey: &str, steamids: &[u64])-> Result, SteamError>
+{
+ if steamids.is_empty()
+ {
+ return Ok(HashSet::new());
+ }
+
+ let mut games_set = get_owned_steam_games(webkey, steamids[0])?;
+
+ if games_set.is_empty()
+ {
+ return Err(SteamError::GamesListEmpty(steamids[0]));
+ }
+
+ for &id in steamids[1..].iter() {
+ let next_set = get_owned_steam_games(webkey, id)?;
+
+ if next_set.is_empty()
+ {
+ return Err(SteamError::GamesListEmpty(id));
+ }
+
+ games_set = &games_set & &next_set; // Intersect the two sets
+
+ if games_set.is_empty()
+ { // No common owned games
+ return Ok(HashSet::new());
+ }
+ }
+
+ return Ok(games_set);
+}
\ No newline at end of file
diff --git a/lib/wcwp_rust/src/wcwp.rs b/lib/wcwp_rust/src/wcwp.rs
new file mode 100644
index 0000000..d017195
--- /dev/null
+++ b/lib/wcwp_rust/src/wcwp.rs
@@ -0,0 +1,19 @@
+use crate::{igdb, steam};
+use crate::errors::WCWPError;
+
+use std::iter::FromIterator;
+
+pub fn intersect_owned_games(webkey: &str, igdb_id: &str, igdb_token: &str, steamids: &[u64]) -> Result, WCWPError>
+{
+ if steamids.is_empty()
+ {
+ return Ok(Vec::new());
+ }
+
+ let games_set = steam::intersect_owned_game_ids(webkey, steamids)?;
+
+ let games_list = Vec::from_iter(games_set.into_iter());
+ let (games_info, _) = igdb::get_steam_game_info(igdb_id, igdb_token, &games_list)?;
+
+ return Ok(games_info);
+}
\ No newline at end of file
diff --git a/static/scripts/app.js b/static/scripts/app.js
index 01af73a..5e1692c 100644
--- a/static/scripts/app.js
+++ b/static/scripts/app.js
@@ -36,6 +36,8 @@ var current_slide_timeout;
var fetching = false;
+var main_user_id = 0;
+
window.addEventListener("load", function() {
submit = document.getElementById("submit-button");
submit.addEventListener("click", submitButtonClicked);
@@ -64,7 +66,7 @@ window.addEventListener("load", function() {
child.src = default_avatar_url;
break;
case "user-name":
- child.innerHTML = "";
+ child.innerText = "";
break;
case "user-checkbox":
delete child.dataset.steamId;
@@ -72,6 +74,8 @@ window.addEventListener("load", function() {
}
}
+ var main_user_info = {}
+
for(var i = 0; i < main_user.children.length; i++)
{
var child = main_user.children[i];
@@ -89,8 +93,19 @@ window.addEventListener("load", function() {
else
{
userCheckboxClicked(child);
+ main_user_id = child.dataset.steamId;
+ main_user_info["steam_id"] = main_user_id;
}
}
+ else if(child.className == "user-name")
+ {
+ main_user_info["screen_name"] = child.children[0].innerText;
+ }
+ }
+
+ if(main_user_id != 0)
+ {
+ user_info[main_user_id] = main_user_info
}
// Fetch friends list
@@ -121,9 +136,9 @@ function submitButtonClicked()
error_div.style.display = "none"
fetching = true;
submit.disabled = true;
- submit.innerHTML = "Fetching..."
+ submit.innerText = "Fetching..."
back.disabled = true;
- back.innerHTML = "Fetching..."
+ back.innerText = "Fetching..."
users_cover.style.display = "block";
if(app.className.includes("on-users") || app.className.includes("slide-to-users"))
@@ -163,9 +178,9 @@ function submitButtonClicked()
.catch(apiError)
.finally(function() {
submit.disabled = false;
- submit.innerHTML = "Find Games";
+ submit.innerText = "Find Games";
back.disabled = false;
- back.innerHTML = "Back";
+ back.innerText = "Back";
fetching = false;
users_cover.style.display = "none"
})
@@ -174,16 +189,16 @@ function submitButtonClicked()
function intersectResponse(data) {
if(data["errcode"] == 1)
{ // User has non-visible games list
- displayError("WhatCanWePlay cannot access the games list of " + user_info[data["user"]]["screen_name"] + ". This either means that their Game details visibility is not Public, or they are being rate-limited by Steam for having too many requests. You can try one of the following fixes:\
-
- Ask " + user_info[data["user"]]["screen_name"] + " to set their Game details to Public\
-
- Remove " + user_info[data["user"]]["screen_name"] + " from your selected users\
+ displayError("WhatCanWePlay cannot access the games list of " + user_info[data["user"]]["screen_name"] + ". This either means that their Game details visibility is not Public, or they are being rate-limited by Steam for having too many requests. You can try one of the following fixes:\
+
- Ask " + user_info[data["user"]]["screen_name"] + " to set their Game details to Public\
+
- Remove " + user_info[data["user"]]["screen_name"] + " from your selected users\
- Try again later\
");
return;
}
else if(data["errcode"] == 2)
{ // User has empty games list
- displayError(user_info[data["user"]]["screen_name"] + " has an empty games list, and cannot possibly share any common games with the selected users. Please deselect " + user_infp[data["user"]]["screen_name"] + " and try again.")
+ displayError("" + user_info[data["user"]]["screen_name"] + " has an empty games list, and cannot possibly share any common games with the selected users. Please deselect " + user_info[data["user"]]["screen_name"] + " and try again.")
return;
}
else if(data["errcode"] != 0)
@@ -284,15 +299,16 @@ function intersectResponse(data) {
}
break;
case "game-title":
- child.innerHTML = game["name"]
+ child.innerText = game["name"]
break;
case "user-count":
Array.from(child.children).forEach(function(child) {
if(child.className == "user-number")
{
- child.innerHTML = game["supported_players"]
- if(game["supported_players"] == "?")
+ child.innerText = game["supported_players"]
+ if(game["supported_players"] == "0")
{
+ child.innerText = "?"
child.classList.add("short")
child.title = "WhatCanWePlay was unable to retrieve the player count for this game from the IGDB"
}
@@ -419,7 +435,7 @@ function friendDataFetched(data)
}
break;
case "user-name":
- child.innerHTML = user["screen_name"];
+ child.innerText = user["screen_name"];
break;
case "user-checkbox":
if(user["visibility"] != 3)
@@ -472,12 +488,12 @@ function userCheckboxClicked(box)
if(len >= 2)
{
submit.disabled = false;
- submit.innerHTML = "Find Games"
+ submit.innerText = "Find Games"
}
else
{
submit.disabled = true;
- submit.innerHTML = "Select " + (len == 0 ? "Two Users" : "One User")
+ submit.innerText = "Select " + (len == 0 ? "Two Users" : "One User")
}
}
else
diff --git a/static/styles/app.css b/static/styles/app.css
index 3017ba0..feeee6e 100644
--- a/static/styles/app.css
+++ b/static/styles/app.css
@@ -306,6 +306,11 @@ html, body {
color: goldenrod;
}
+.err-user-name {
+ font-style: italic;
+ font-size: larger;
+}
+
/* Desktop Overrides */
@media (min-width: 601px) {
#app {
diff --git a/static/styles/default_dark.css b/static/styles/default_dark.css
index 09e5e45..176237d 100644
--- a/static/styles/default_dark.css
+++ b/static/styles/default_dark.css
@@ -79,9 +79,21 @@ html, body {
color:gold; /* give me money */
}
+#commit {
+ font-size: smaller;
+ font-family: 'Courier New', Courier, monospace;
+}
+
/* Desktop Overrides */
@media (min-width: 601px) {
#body-box {
max-width: 1000px;
}
+}
+
+/* Mobile Overrides */
+@media (max-width: 600px) {
+ #commit {
+ display: none;
+ }
}
\ No newline at end of file
diff --git a/steam_utils.py b/steam_utils.py
deleted file mode 100644
index 871b94b..0000000
--- a/steam_utils.py
+++ /dev/null
@@ -1,179 +0,0 @@
-# This file is a part of WhatCanWePlay
-# Copyright (C) 2020 TGRCDev
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published
-# by the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-import requests
-from requests.exceptions import ConnectTimeout, ReadTimeout
-from typing import Mapping, Any, Collection, Dict, List, Optional
-import json
-from os import path
-
-api_base = "https://api.steampowered.com/"
-
-root_path = path.dirname(__file__)
-config = json.load(open(path.join(root_path, "config.json"), "r"))
-debug = config.get("debug", config.get("DEBUG", False))
-
-# Fetches public information about a list of Steam users
-#
-# Returns: Dictionary
-# ["errcode"]: Integer code that explains what kind of error occurred, if one did.
-#
-# get_steam_user_info errcodes:
-# 0: no error
-# 1: bad api key
-# 2: connect timeout (Steam took too long to respond)
-# 3: read timeout (Steam responded but took too long to send info)
-# -1: unknown error
-#
-# ["users"]: Dictionary of all users retrieved. The key is a steam id, and the value is a dictionary of data.
-# User Dictionary Format:
-# ["exists"]: Boolean value that is always set. True if the profile exists and has been set up, False otherwise
-# ["steam_id"]: Steam ID (integer)
-# ["screen_name"]: The user's Steam screen name
-# ["avatar_thumb"]: A url to a 32x32 size version of their Steam avatar picture
-# ["avatar"]: A url to a 64x64 size version of their Steam avatar picture
-# ["visibility"]: The user's profile visibility. 1 = Private, 2 = Friends Only, 3 = Public
-def get_steam_user_info(webkey: str, steamids: Collection[int], connect_timeout: Optional[float] = None, read_timeout: Optional[float] = None) -> Dict[int, Dict[str, Any]]:
- if len(steamids) == 0:
- return {"errcode": 0, "users":{}} # Technically not an error
-
- return_dict = {"errcode": 0}
- user_dict = {int(steam_id): {"exists": False} for steam_id in steamids}
-
- retrieved_users = 0
- while retrieved_users < len(steamids):
- request_users = steamids[retrieved_users:100]
- steamid_str = ",".join(map(str, request_users))
- retrieved_users += len(request_users)
- try:
- r = requests.get(
- api_base + "ISteamUser/GetPlayerSummaries/v2/",
- {"key": webkey, "steamids": steamid_str, "format":"json"},
- timeout=(connect_timeout, read_timeout)
- )
- except ConnectTimeout:
- return {"errcode": 2}
- except ReadTimeout:
- return {"errcode": 3}
- if r.status_code == 403:
- return {"errcode": 1}
- elif r.status_code != 200:
- if debug:
- r.raise_for_status()
- return {"errcode": -1}
-
- response = r.json()["response"]
-
- for user in response["players"]:
- if user.get("profilestate", 0) == 1:
- user_info = {"exists": True}
- steam_id = int(user["steamid"])
- user_info["steam_id"] = steam_id
- user_info["screen_name"] = user["personaname"]
- user_info["avatar_thumb"] = user["avatar"]
- user_info["avatar"] = user["avatarmedium"]
- user_info["visibility"] = user["communityvisibilitystate"]
- user_info["online"] = user.get("personastate", 0) != 0
- user_dict[steam_id] = user_info
-
- return_dict["users"] = user_dict
-
- return return_dict
-
-# Fetch a list of Steam app IDs that a user owns
-#
-# Returns: Dictionary
-# ["errcode"]: Integer code that explains what kind of error occurred, if one did.
-#
-# get_owned_steam_games errcodes:
-# 0: no error
-# 1: bad api key
-# 2: connect timeout (Steam took too long to respond)
-# 3: read timeout (Steam responded but took too long to send info)
-# 4: games not visible
-# -1: unknown error
-#
-# ["games"]: List of owned Steam App IDs
-def get_owned_steam_games(webkey: str, steamid: int, include_free_games: bool=False, connect_timeout: float = 0.0, read_timeout: float = 0.0) -> Dict[str, Any]:
- try:
- r = requests.get(
- api_base + "IPlayerService/GetOwnedGames/v0001/",
- {
- "key": webkey,
- "steamid": steamid,
- "include_appinfo": False,
- "include_played_free_games": include_free_games,
- "format": "json"},
- timeout=(connect_timeout, read_timeout)
- )
- except ConnectTimeout:
- return {"errcode": 2}
- except ReadTimeout:
- return {"errcode": 3}
-
- if r.status_code == 403:
- return {"errcode": 1}
- elif r.status_code != 200:
- if debug:
- r.raise_for_status()
- return {"errcode": -1}
-
- response = r.json()["response"]
- if not "game_count" in response.keys():
- return {"errcode": 4}
- else:
- return {"errcode": 0, "games": [game["appid"] for game in response.get("games", [])]}
-
-# Fetch a list of Steam IDs that are friends with the user
-#
-# Returns: Dictionary
-# ["errcode"]: Integer code that explains what kind of error occurred, if one did.
-#
-# get_steam_user_friend_list errcodes:
-# 0: no error
-# 1: bad api key
-# 2: connect timeout (Steam took too long to respond)
-# 3: read timeout (Steam responded but took too long to send info)
-# 4: friends not visible
-# -1: unknown error
-#
-# ["friends"]: List of Steam IDs that are friends
-def get_steam_user_friend_list(webkey: str, steamid: int, connect_timeout: float = 0, read_timeout: float = 0) -> Dict[str, Any]:
- try:
- r = requests.get(
- api_base + "ISteamUser/GetFriendList/v0001/",
- {"key": webkey,
- "steamid": steamid,
- "relationship": "friend",
- "format": "json"},
- timeout=(connect_timeout, read_timeout)
- )
- except ConnectTimeout:
- return {"errcode": 2}
- except ReadTimeout:
- return {"errcode": 3}
- if r.status_code == 403:
- return {"errcode": 1}
- elif r.status_code != 200:
- if debug:
- r.raise_for_status()
- return {"errcode": -1}
-
- response = r.json()
- if "friendslist" not in response.keys() or "friends" not in response["friendslist"].keys():
- return {"errcode": 4}
- else:
- return {"errcode": 0, "friends": [int(friend["steamid"]) for friend in response["friendslist"]["friends"]]}
\ No newline at end of file
diff --git a/templates/base_page.html b/templates/base_page.html
index c65b927..f0fbd61 100644
--- a/templates/base_page.html
+++ b/templates/base_page.html
@@ -60,9 +60,12 @@ along with this program. If not, see .
{% endblock %}