diff --git a/application/alembic/versions/database_v3.py b/application/alembic/versions/database_v3.py index c619ac5..4d890d9 100644 --- a/application/alembic/versions/database_v3.py +++ b/application/alembic/versions/database_v3.py @@ -1,6 +1,6 @@ """Add updates to the game server table. -Revision ID: database_v1 +Revision ID: database_v3 Revises: Create Date: 2023-10-08 14:10:31.088339 @@ -26,7 +26,9 @@ def upgrade(): sa.String(length=25), default=GameStates.NOT_STATE.value, nullable=False, - ) + ), + insert_after="", + insert_before="", ) # ### end Alembic commands ### diff --git a/application/alembic/versions/database_v4.py b/application/alembic/versions/database_v4.py new file mode 100644 index 0000000..6af58d8 --- /dev/null +++ b/application/alembic/versions/database_v4.py @@ -0,0 +1,77 @@ +"""Add actions table for tracking game state + +Revision ID: database_v4 +Revises: +Create Date: 2023-10-08 14:10:31.088339 + +""" +from alembic import op +import sqlalchemy as sa + +from datetime import datetime + +# revision identifiers, used by Alembic. +revision = "database_v4" +down_revision = "database_v3" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "actions", + sa.Column("action_id", sa.Integer(), nullable=False), + sa.Column("game_id", sa.Integer(), nullable=False), + sa.Column("type", sa.String(length=100), nullable=False), + sa.Column("result", sa.String(length=100), nullable=True), + sa.Column("owner", sa.String(length=100), nullable=False, default="NONE"), + sa.Column("timestamp", sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column("spare", sa.String(length=100), nullable=True), + sa.PrimaryKeyConstraint("action_id"), + ) + + with op.batch_alter_table("games", schema=None) as batch_op: + batch_op.create_foreign_key( + op.f("fk_actions_game_game_id"), + "actions", + ["game_id"], + ["game_id"], + ) + + batch_op.add_column( + sa.Column( + "game_steam_build_branch", + sa.String(length=256), + default="public", + nullable=False, + ), + insert_after="public", + insert_before="public", + ) + + batch_op.add_column( + sa.Column( + "game_steam_build_id", + sa.Integer(), + default=-1, + nullable=False, + ), + insert_after=-1, + insert_before=-1, + ) + + op.create_index("ix_actions_action_id", "actions", ["action_id"], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("actions") + + op.drop_constraint("fk_actions_game_game_id", "games", type_="foreignkey") + + with op.batch_alter_table("games", schema=None) as batch_op: + batch_op.drop_column("game_steam_build_id") + batch_op.drop_column("game_steam_build_branch") + # ### end Alembic commands ### diff --git a/application/api/controllers/games.py b/application/api/controllers/games.py index fbbfc28..0320491 100644 --- a/application/api/controllers/games.py +++ b/application/api/controllers/games.py @@ -1,6 +1,9 @@ from flask import request +from sqlalchemy import desc -from application.common import toolbox +from application.common import logger, toolbox +from application.extensions import DATABASE +from application.models.actions import Actions from application.models.games import Games @@ -13,9 +16,25 @@ def get_all_games(add_server_status=False): Games.query, page, per_page, "game.get_all_games" ) + # For a server with 2-5 games, this for loop is not a problem. The author knows that + # a join is more optimal for database items. + game_items = games_dict["items"] + + # Mixin other items, like actions + for game in game_items: + actions_qry = Actions.query.filter_by(game_id=game["game_id"]).order_by( + desc(Actions.timestamp) + ) + + # This wil get the first per_page items (10 by default) + actions_dict = Actions.to_collection_dict( + actions_qry, page, per_page, "game.get_all_games" + ) + game["actions"] = actions_dict["items"] + if add_server_status: - game_items = games_dict["items"] for game in game_items: + # From .py file not db. game_obj = toolbox._get_supported_game_object(game["game_name"]) game["game_exe"] = game_obj._game_executable has_pid = True if game["game_pid"] != "null" else False @@ -30,10 +49,26 @@ def get_all_games(add_server_status=False): @staticmethod def get_game_by_name(game_name): game_query = Games.query.filter_by(game_name=game_name) - return Games.to_collection_dict( + game_dict = Games.to_collection_dict( game_query, 1, 1, "game.get_game_by_name", game_name=game_name ) + if len(game_dict["items"]) == 0: + return game_dict + + game_item = game_dict["items"][0] + actions_qry = Actions.query.filter_by(game_id=game_item["game_id"]).order_by( + desc(Actions.timestamp) + ) + + # This wil get the first per_page items (10 by default) + actions_dict = Actions.to_collection_dict( + actions_qry, 1, 10, "game.get_game_by_name", game_name=game_name + ) + game_dict["actions"] = actions_dict["items"] + + return game_dict + @staticmethod def get_games_schema(): @@ -76,3 +111,23 @@ def get_game_server_status(game_name: str) -> str: ) return response + + +def update_game(game_id: int, request) -> bool: + payload = request.json + + game_qry = Games.query.filter_by(game_id=game_id) + game_obj = game_qry.first() + + if game_obj is None: + logger.error(f"Error: Game ID: {game_id} does not exist") + return False + + try: + game_qry.update(payload) + DATABASE.session.commit() + except Exception: + logger.critical("Error: update_game - Unable to update database.") + return False + + return True diff --git a/application/api/v1/blueprints/architect.py b/application/api/v1/blueprints/architect.py index 5b8b6c6..6747bda 100644 --- a/application/api/v1/blueprints/architect.py +++ b/application/api/v1/blueprints/architect.py @@ -2,6 +2,7 @@ from application.common import logger from application.common.decorators import authorization_required +from application.managers.steam_manager import SteamUpdateManager from application.api.controllers import architect as architect_controller from application.api.controllers import games as games_controller @@ -25,6 +26,7 @@ def health_secure(): @architect.route("/agent/info", methods=["GET"]) @authorization_required def agent_info(): + steam_mgr = SteamUpdateManager() info = {} platform_dict = architect_controller.get_platform_info() @@ -32,6 +34,16 @@ def agent_info(): games = games_controller.get_all_games(add_server_status=True) games = games["items"] + # TODO - Tech Debt: Update agent info page to get this info over websocket. Works for now + # but does not scale. + for game in games: + update_dict = steam_mgr.is_update_required( + game["game_steam_build_id"], + game["game_steam_build_branch"], + game["game_steam_id"], + ) + game["update_required"] = update_dict["is_required"] + info: dict = platform_dict info.update({"games": games}) diff --git a/application/api/v1/blueprints/game.py b/application/api/v1/blueprints/game.py index 11ad382..8f70b84 100644 --- a/application/api/v1/blueprints/game.py +++ b/application/api/v1/blueprints/game.py @@ -10,6 +10,7 @@ from application.common.exceptions import InvalidUsage from application.common.game_base import BaseGame from application.extensions import DATABASE +from application.managers.steam_manager import SteamUpdateManager from application.models.games import Games from application.models.game_arguments import GameArguments @@ -40,6 +41,15 @@ def get_game_schema(): return jsonify(games_controller.get_games_schema()) +@game.route("/game/", methods=["PATCH"]) +@authorization_required +def update_game_by_id(game_id: int): + if not games_controller.update_game(game_id, request): + raise InvalidUsage("Error, Unable to update game data", status_code=500) + + return "Success", 200 + + @game.route("/game/status/", methods=["GET"]) @authorization_required def get_game_server_status(game_name): @@ -125,6 +135,35 @@ def game_uninstall(game_name): raise InvalidUsage(message, status_code=500) +@game.route("/game/update/check/", methods=["GET"]) +@authorization_required +def game_check_for_update(game_id: int): + logger.info("Checking if Game Requireds an update") + + game_obj = Games.query.filter_by(game_id=game_id).first() + + if game_obj is None: + message = f"Game Server Update Check: Game ID {game_id} does not exist!" + logger.critical(message) + raise InvalidUsage(message, status_code=400) + + steam_mgr = SteamUpdateManager() + + try: + app_info = steam_mgr.is_update_required( + game_obj.game_steam_build_id, + game_obj.game_steam_build_branch, + game_obj.game_steam_id, + ) + except Exception as error: + logger.critical(error) + raise InvalidUsage( + "Unable to determine if update is required.", status_code=500 + ) + + return jsonify(app_info) + + class GameArgumentsApi(MethodView): def __init__(self, model): self.model = model diff --git a/application/api/v1/blueprints/steam.py b/application/api/v1/blueprints/steam.py index 409384b..e2a015d 100644 --- a/application/api/v1/blueprints/steam.py +++ b/application/api/v1/blueprints/steam.py @@ -1,9 +1,14 @@ +from datetime import datetime from flask import Blueprint, request, jsonify from application.common import logger +from application.common.constants import GameActionTypes from application.common.decorators import authorization_required from application.common.exceptions import InvalidUsage +from application.extensions import DATABASE from application.managers.steam_manager import SteamManager +from application.models.actions import Actions +from application.models.games import Games steam = Blueprint("steam", __name__, url_prefix="/v1") @@ -51,6 +56,22 @@ def steam_app_install(): payload["password"], ) + game_obj = Games.query.filter_by( + game_steam_id=steam_id, game_install_dir=payload["install_dir"] + ).first() + + try: + new_action = Actions() + new_action.type = GameActionTypes.INSTALLING.value + new_action.game_id = game_obj.game_id + new_action.result = install_thread.native_id + DATABASE.session.add(new_action) + DATABASE.session.commit() + except Exception: + message = "SteamManager: install_steam_app -> Error: Failed to update database." + logger.critical(message) + raise InvalidUsage(message, status_code=500) + payload.pop("steam_install_path") payload.pop("steam_id") payload.pop("install_dir") @@ -104,6 +125,24 @@ def steam_app_update(): payload["password"], ) + game_qry = Games.query.filter_by( + game_steam_id=steam_id, game_install_dir=payload["install_dir"] + ) + game_obj = game_qry.first() + + try: + new_action = Actions() + new_action.type = GameActionTypes.UPDATING.value + new_action.game_id = game_obj.game_id + new_action.result = update_thread.native_id + game_obj.game_last_update = datetime.now() + DATABASE.session.add(new_action) + DATABASE.session.commit() + except Exception: + message = "SteamManager: install_steam_app -> Error: Failed to update database." + logger.critical(message) + raise InvalidUsage(message, status_code=500) + payload.pop("steam_install_path") payload.pop("steam_id") payload.pop("install_dir") @@ -121,9 +160,34 @@ def steam_app_update(): ) -# TODO - Implement this functionality. +# TODO - Deprecate this - Was implemented in game_base. No longer needed. @steam.route("/steam/app/remove", methods=["POST"]) @authorization_required def steam_app_remove(): logger.info("Remote uninstalls of game servers Not Yet Implemented") return "Success" + + +@steam.route("/steam/app/build/id", methods=["POST"]) +@authorization_required +def steam_app_get_build_id(): + logger.info("Getting Steam Application Manifest Build ID") + + payload = request.json + + required_data = ["steam_install_path", "game_install_path", "steam_id"] + + if not set(required_data).issubset(set(list(payload.keys()))): + message = "Error: Missing Required Data" + logger.error(message) + logger.info(payload.keys()) + raise InvalidUsage(message, status_code=400) + + steam_id = payload["steam_id"] + steam_install_path = payload["steam_install_path"] + game_install_path = payload["game_install_path"] + + steam_mgr = SteamManager(steam_install_path, force_steam_install=False) + app_info = steam_mgr.get_build_id_from_app_manifest(game_install_path, steam_id) + + return jsonify(app_info) diff --git a/application/common/constants.py b/application/common/constants.py index 6f7e69b..c265bef 100644 --- a/application/common/constants.py +++ b/application/common/constants.py @@ -12,22 +12,23 @@ class DeployTypes(Enum): PYTHON = "python" +class GameActionTypes(Enum): + INSTALLING = "installing" + UPDATING = "updating" + STARTING = "starting" + STOPPING = "stopping" + RESTARTING = "restarting" + UNINSTALLING = "uninstalling" + + class GameStates(Enum): NOT_STATE = "NO_STATE" - INSTALLING = "installing" - INSTALLED = "installed" INSTALL_FAILED = "install_failed" - UPDATING = "updating" - UPDATED = "updated" UPDATE_FAILED = "update_failed" - STARTING = "starting" STARTED = "started" STARTUP_FAILED = "startup_failed" - STOPPING = "stopping" STOPPED = "stopped" SHUTDOWN_FAILED = "shutdown_failed" - RESTARTING = "restarting" - UNINSTALLING = "uninstalling" UNINSTALL_FAILED = "uninstall_failed" diff --git a/application/common/game_base.py b/application/common/game_base.py index 8fdbd2a..1ac18c3 100644 --- a/application/common/game_base.py +++ b/application/common/game_base.py @@ -50,12 +50,16 @@ def uninstall(self) -> bool: game_obj = Games.query.filter_by(game_name=self._game_name).first() game_arg_objs = GameArguments.query.filter_by(game_id=game_obj.game_id).all() + actions = game_obj.get_all_actions() + # But first save off the installation path. game_install_dir = game_obj.game_install_dir try: for argument in game_arg_objs: DATABASE.session.delete(argument) + for action in actions: + DATABASE.session.delete(action) DATABASE.session.delete(game_obj) DATABASE.session.commit() except Exception as e: diff --git a/application/common/steam_manifest_parser.py b/application/common/steam_manifest_parser.py new file mode 100644 index 0000000..8067433 --- /dev/null +++ b/application/common/steam_manifest_parser.py @@ -0,0 +1,58 @@ +import os + + +def read_dir(dir: str): + steamapps = {} + + for file in os.listdir(dir): + if file.endswith(".acf"): + acf = read_acf(dir + file) + steamapps[acf["appid"]] = acf + + return steamapps + + +def read_acf(acffile: str) -> dict: + with open(acffile, "r") as file: + content = [line.replace("\t\t", "") for line in file] + content = [line.strip("\t\n") for line in content] + + return parser(content)[0] + + +def parser(content: str, index=0) -> dict: + appstate = {} + i = index + + while i < len(content): + x = content[i] + + if x == '"AppState"' or x == "{" or x == "}": + i = i + 1 + continue + + # Due to a new variable called "BetaConfig", blanks cannot be removed completely. + # Consider the example: ['', 'BetaKey', '', ''] + # Removing all '' elements would leave only 'BetaKey' + # Thus the older system would work best. + + # TODO: An alternative and cleaner implementation soon. + line = x.split('"') + + # Peek forward for an opening curly brace + if content[i + 1] == "{": + # Pass back to parser and increment index forward + appstate[line[1]], i = parser(content, i + 1) + i = i + 1 + # Continue to loop till a closing curly brace is found + continue + + elif content[i + 1] == "}": + # Create dict and return + appstate[line[1]] = line[3] + return appstate, i + + appstate[line[1]] = line[3] + i = i + 1 + + return appstate, i diff --git a/application/common/toolbox.py b/application/common/toolbox.py index c031032..6d12af7 100644 --- a/application/common/toolbox.py +++ b/application/common/toolbox.py @@ -146,7 +146,7 @@ def get_size(bytes, suffix="B"): @staticmethod -def update_game_state(game_data: {}, new_state: GameStates) -> True: +def update_game_state(game_data: dict, new_state: GameStates) -> True: update_success = True game_qry = None diff --git a/application/gui/intalled_games_menu.py b/application/gui/intalled_games_menu.py index 0fa48aa..8147d4e 100644 --- a/application/gui/intalled_games_menu.py +++ b/application/gui/intalled_games_menu.py @@ -39,6 +39,14 @@ def update_menu_list(self, initialize=False, delay_sec=0): all_games = self._init_data else: all_games = self._client.game.get_games() + + # Error checking. If backend server goes down, don't want to crash. + if all_games is None: + logger.critical( + "InstalledGameMenu::update_menu_list - Error - Is Server Offline?" + ) + return + all_games = all_games["items"] for game in all_games: diff --git a/application/gui/launch.py b/application/gui/launch.py index 99227ba..d98c836 100644 --- a/application/gui/launch.py +++ b/application/gui/launch.py @@ -33,6 +33,7 @@ def __init__(self, globals_obj: GuiGlobals) -> None: "http://" + self._globals._server_host, self._globals._server_port, verbose=False, + timeout=10, ) self._globals._nginx_manager = NginxManager(self._globals._client) @@ -81,9 +82,16 @@ def quit_gui(self): def _launch_game_manager_window(self): # This has to be the current games, not init data. - games = self._globals._client.game.get_games() + all_games = self._globals._client.game.get_games() - if len(games["items"]) == 0: + # Error checking. If backend server goes down, don't want to crash. + if all_games is None: + logger.critical( + "GuiApp::_launch_game_manager_window - Error - Is Server Offline?" + ) + return + + if len(all_games["items"]) == 0: message = QMessageBox() message.setText("Please install a game before using the Game Manager!") message.exec() diff --git a/application/gui/resources/agent-black.ico b/application/gui/resources/agent-black.ico index 58b283b..6f0ff18 100644 Binary files a/application/gui/resources/agent-black.ico and b/application/gui/resources/agent-black.ico differ diff --git a/application/gui/resources/agent-black.png b/application/gui/resources/agent-black.png index 2d9106d..984ac91 100644 Binary files a/application/gui/resources/agent-black.png and b/application/gui/resources/agent-black.png differ diff --git a/application/gui/resources/agent-green.png b/application/gui/resources/agent-green.png index 383368d..252a184 100644 Binary files a/application/gui/resources/agent-green.png and b/application/gui/resources/agent-green.png differ diff --git a/application/gui/resources/agent-white.ico b/application/gui/resources/agent-white.ico index ee026eb..0aba271 100644 Binary files a/application/gui/resources/agent-white.ico and b/application/gui/resources/agent-white.ico differ diff --git a/application/gui/resources/agent-white.png b/application/gui/resources/agent-white.png index ac63faa..a307774 100644 Binary files a/application/gui/resources/agent-white.png and b/application/gui/resources/agent-white.png differ diff --git a/application/gui/resources/old-images/agent-black.ico b/application/gui/resources/old-images/agent-black.ico new file mode 100644 index 0000000..58b283b Binary files /dev/null and b/application/gui/resources/old-images/agent-black.ico differ diff --git a/application/gui/resources/old-images/agent-black.png b/application/gui/resources/old-images/agent-black.png new file mode 100644 index 0000000..2d9106d Binary files /dev/null and b/application/gui/resources/old-images/agent-black.png differ diff --git a/application/gui/resources/old-images/agent-green.png b/application/gui/resources/old-images/agent-green.png new file mode 100644 index 0000000..383368d Binary files /dev/null and b/application/gui/resources/old-images/agent-green.png differ diff --git a/application/gui/resources/old-images/agent-white.ico b/application/gui/resources/old-images/agent-white.ico new file mode 100644 index 0000000..ee026eb Binary files /dev/null and b/application/gui/resources/old-images/agent-white.ico differ diff --git a/application/gui/resources/old-images/agent-white.png b/application/gui/resources/old-images/agent-white.png new file mode 100644 index 0000000..ac63faa Binary files /dev/null and b/application/gui/resources/old-images/agent-white.png differ diff --git a/application/gui/widgets/game_manager_widget.py b/application/gui/widgets/game_manager_widget.py index 8762557..eb1abf3 100644 --- a/application/gui/widgets/game_manager_widget.py +++ b/application/gui/widgets/game_manager_widget.py @@ -23,7 +23,7 @@ class GameManagerWidget(QWidget): - REFRESH_INTERVAL = 10 * constants.MILIS_PER_SECOND + REFRESH_INTERVAL = 15 * constants.MILIS_PER_SECOND FAST_INTERVAL = 1 * constants.MILIS_PER_SECOND def __init__(self, client: Operator, globals, parent: QWidget) -> None: @@ -59,6 +59,8 @@ def __init__(self, client: Operator, globals, parent: QWidget) -> None: self._add_arg_btn: QPushButton = None self._game_pid_label: QLabel = None self._game_exe_found_label: QLabel = None + self._game_current_build: QLabel = None + self._game_update_required: QLabel = None def _get_game_object(self, game_name): for module_name in self._modules_dict.keys(): @@ -159,8 +161,24 @@ def _refresh_on_timer(self): game_data = response_data["items"][0] game_pid = game_data["game_pid"] + game_id = game_data["game_id"] + current_build_id = game_data["game_steam_build_id"] + current_build_branch = game_data["game_steam_build_branch"] + is_game_pid = False + self._game_current_build.setText(str(current_build_id)) + self._game_build_branch.setText(current_build_branch) + + update_data = self._client.game.check_for_update(game_id) + + if update_data: # not None + is_required = update_data["is_required"] + required_text = "Yes" if is_required else "No" + self._game_update_required.setText(required_text) + else: + self._game_update_required.setText("Unknown") + if game_pid is None: self._game_pid_label.setText("Game PID Not in Database") is_game_pid = False @@ -240,6 +258,9 @@ def _build_game_frame(self, game_name): v_layout_info_labels.addWidget(QLabel("Game Pretty Name", game_frame)) v_layout_info_labels.addWidget(QLabel("Game Exe Name", game_frame)) v_layout_info_labels.addWidget(QLabel("Game Steam ID", game_frame)) + v_layout_info_labels.addWidget(QLabel("Game Build ID", game_frame)) + v_layout_info_labels.addWidget(QLabel("Game Build Branch", game_frame)) + v_layout_info_labels.addWidget(QLabel("Game Update Required?", game_frame)) v_layout_info_labels.addWidget(QLabel("Game PID", game_frame)) v_layout_info_labels.addWidget(QLabel("Game Exe Found?", game_frame)) v_layout_info_labels.addWidget(QLabel("Game Info URL", game_frame)) @@ -257,9 +278,15 @@ def _build_game_frame(self, game_name): QLabel(game_object._game_steam_id, game_frame) ) + self._game_current_build = QLabel("", game_frame) + self._game_build_branch = QLabel("", game_frame) + self._game_update_required = QLabel("", game_frame) self._game_pid_label = QLabel("", game_frame) self._game_exe_found_label = QLabel("", game_frame) + v_layout_info_info_text.addWidget(self._game_current_build) + v_layout_info_info_text.addWidget(self._game_build_branch) + v_layout_info_info_text.addWidget(self._game_update_required) v_layout_info_info_text.addWidget(self._game_pid_label) v_layout_info_info_text.addWidget(self._game_exe_found_label) @@ -359,6 +386,9 @@ def _text_changed(self, game_pretty_name): old_game_frame.hide() self._layout.replaceWidget(old_game_frame, self._current_game_frame) + self._disable_all_btns() + self._refresh_on_timer() + def _executable_is_found(self, exe_name: str) -> bool: return True if toolbox._get_proc_by_name(exe_name) else False @@ -414,6 +444,7 @@ def _update_game(self, game_name): ) game_info = self._client.game.get_game_by_name(game_name) + game_id = game_info["items"][0]["game_id"] steam_id = game_info["items"][0]["game_steam_id"] install_path = game_info["items"][0]["game_install_dir"] @@ -429,6 +460,15 @@ def _update_game(self, game_name): thread_alive = self._client.app.is_thread_alive(thread_ident) time.sleep(1) + steam_build_id = self._client.steam.get_steam_app_build_id( + steam_install_dir, install_path, steam_id + ) + + if steam_build_id: + self._client.game.update_game_data( + game_id, game_steam_build_id=steam_build_id + ) + message = QMessageBox(self) message.setWindowTitle("Complete") message.setText("Game Server Update is now complete!") diff --git a/application/gui/widgets/new_game_widget.py b/application/gui/widgets/new_game_widget.py index 9d7a004..015139e 100644 --- a/application/gui/widgets/new_game_widget.py +++ b/application/gui/widgets/new_game_widget.py @@ -267,6 +267,19 @@ def _install_game(self, game_pretty_name): self._install_games_menu.update_menu_list() + # Get the game now, that it's been installed. + game_data = self._client.game.get_game_by_name(game_name) + game_id = game_data["items"][0]["game_id"] + + steam_build_id = self._client.steam.get_steam_app_build_id( + steam_install_dir, install_path, steam_id + ) + + if steam_build_id: + self._client.game.update_game_data( + game_id, game_steam_build_id=steam_build_id + ) + message = QMessageBox(self) message.setWindowTitle("Complete") message.setText(f"Installation of {game_pretty_name}, complete!") diff --git a/application/managers/steam_manager.py b/application/managers/steam_manager.py index dd47c4c..3877c11 100644 --- a/application/managers/steam_manager.py +++ b/application/managers/steam_manager.py @@ -1,4 +1,5 @@ import os +import requests import subprocess from datetime import datetime @@ -10,11 +11,69 @@ from application.models.games import Games from application.common import logger, toolbox, constants from application.common.exceptions import InvalidUsage +from application.common.steam_manifest_parser import read_acf from application.extensions import DATABASE +class SteamUpdateManager: + def __init__(self) -> None: + self._base_format_url = "https://api.steamcmd.net/v1/info/{STEAM_ID}" + + def _get_info_url(self, steam_id: int) -> str: + return self._base_format_url.format(STEAM_ID=steam_id) + + def _get_build_id(self, steam_id: int, branch: str = "public") -> int: + build_id = None + + response = requests.get(self._get_info_url(steam_id)) + + if response.status_code != 200: + logger.critical( + "SteamUpdateManager: Unable to contact steamcmd.net to get build id " + f"for branch, {branch}" + ) + return None + + json_data = response.json() + data = json_data["data"] + + app_data = data[str(steam_id)] + depots = app_data["depots"] + branches = depots["branches"] + inquery_branch = branches[branch] + build_id = inquery_branch["buildid"] + + return int(build_id) + + def is_update_required( + self, current_build_id: int, current_build_branch: int, current_steam_id: int + ) -> dict: + update_required = False + + published_build_id = self._get_build_id( + current_steam_id, branch=current_build_branch + ) + + if published_build_id is None: + logger.critical( + "SteamUpdateManager: Unable to determine if game requries update." + ) + return None + + if current_build_id < published_build_id: + update_required = True + + output_dict = { + "is_required": update_required, + "current_version": current_build_id, + "target_version": published_build_id, + } + + return output_dict + + class SteamManager: - def __init__(self, steam_install_dir) -> None: + def __init__(self, steam_install_dir, force_steam_install=True) -> None: if not os.path.exists(steam_install_dir): os.makedirs(steam_install_dir, mode=0o777, exist_ok=True) @@ -22,7 +81,8 @@ def __init__(self, steam_install_dir) -> None: self._steam = Steamcmd(steam_install_dir, constants.DEFAULT_INSTALL_PATH) - self._steam.install(force=True) + if not force_steam_install: + self._steam.install(force=True) toolbox.recursive_chmod(steam_install_dir) @@ -106,6 +166,23 @@ def update_steam_app( ) -> Thread: return self._run_install_on_thread(steam_id, installation_dir, user, password) + def get_build_id_from_app_manifest(self, installation_dir, steam_id): + build_id = None + app_manifest = None + + # Now read in the build id from the .acf file. + manifest_file = f"appmanifest_{steam_id}.acf" + acf_file = os.path.join(installation_dir, "steamapps", manifest_file) + + if not os.path.exists(acf_file): + return None + + app_manifest = read_acf(acf_file) + + build_id = app_manifest["buildid"] + + return build_id + def _update_gamefiles( self, gameid, game_install_dir, user="anonymous", password=None, validate=False ) -> bool: diff --git a/application/models/actions.py b/application/models/actions.py new file mode 100644 index 0000000..052cc2e --- /dev/null +++ b/application/models/actions.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from application.extensions import DATABASE +from application.common.pagination import PaginatedApi + + +class Actions(PaginatedApi, DATABASE.Model): + __tablename__ = "actions" + + action_id = DATABASE.Column(DATABASE.Integer, primary_key=True) + + game_id = DATABASE.Column( + DATABASE.Integer, DATABASE.ForeignKey("games.game_id"), nullable=False + ) + + type = DATABASE.Column(DATABASE.String(100), nullable=False) + result = DATABASE.Column(DATABASE.String(100), nullable=True) + owner = DATABASE.Column(DATABASE.String(100), nullable=False, default="NONE") + timestamp = DATABASE.Column( + DATABASE.DateTime, nullable=False, default=datetime.utcnow + ) + spare = DATABASE.Column(DATABASE.String(100), nullable=True) + + def to_dict(self): + data = {} + + for column in self.__table__.columns: + field = column.key + + if getattr(self, field) == []: + continue + + data[field] = getattr(self, field) + + return data diff --git a/application/models/games.py b/application/models/games.py index b36d421..90f5378 100644 --- a/application/models/games.py +++ b/application/models/games.py @@ -3,12 +3,17 @@ from application.extensions import DATABASE from application.common.constants import GameStates from application.common.pagination import PaginatedApi +from application.models.actions import Actions class Games(PaginatedApi, DATABASE.Model): __tablename__ = "games" game_id = DATABASE.Column(DATABASE.Integer, primary_key=True) game_steam_id = DATABASE.Column(DATABASE.Integer, unique=True, nullable=False) + game_steam_build_id = DATABASE.Column(DATABASE.Integer, nullable=False, default=-1) + game_steam_build_branch = DATABASE.Column( + DATABASE.String(256), nullable=False, default="public" + ) game_install_dir = DATABASE.Column( DATABASE.String(256), unique=True, nullable=False ) @@ -21,12 +26,34 @@ class Games(PaginatedApi, DATABASE.Model): DATABASE.DateTime, default=datetime.utcnow, nullable=False ) game_last_update = DATABASE.Column( - DATABASE.DateTime, default=datetime.utcnow, nullable=False + DATABASE.DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, ) game_state = DATABASE.Column( DATABASE.String(25), default=GameStates.NOT_STATE.value, nullable=False ) + actions = DATABASE.relationship( + "Actions", + foreign_keys="Actions.game_id", + backref="actions", + lazy="dynamic", + ) + + def get_all_actions(self): + return self.actions.all() + + def get_game_actions(self, game_name, action=None): + game_obj = Games.query.filter_by(game_name=game_name).first() + if action: + query = Actions.query.filter_by(game_id=game_obj.game_id) + else: + query = Actions.query.filter_by(game_id=game_obj.game_id, type=action) + + return query.all() + def to_dict(self): data = {}