commit 7db63e2c7dcf48d9ec17f2f182d31bb5242f104f Author: LunaChocken Date: Sat Jun 7 16:42:31 2025 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f7550b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/README.md b/README.md new file mode 100644 index 0000000..69db097 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ + + + + + +[![asciicast](https://asciinema.org/a/qfN1Nc4Sy7swPJVXUeWsmSDqx.svg)](https://asciinema.org/a/qfN1Nc4Sy7swPJVXUeWsmSDqx) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..3279fec --- /dev/null +++ b/main.py @@ -0,0 +1,66 @@ +# CLI app to find Steam game paths with Compatdata +from rich import print +from prompt_toolkit import prompt +from rich.console import Console +from rich.columns import Columns +from rich.panel import Panel +import vdfparser as vd +from prompt_helper import PromptHelper + +class SteamGamePathTool: + def __init__(self): + self.steam_path = vd.fetch_steam_path() + self.steam_vdf_path = vd.fetch_steam_vdf() + + if self.steam_vdf_path is None: + print("Steam VDF file not found") + # Add user input to attempt to find + self.steam_vdf_path = prompt("Enter the path to the Steam VDF file: ", default=self.steam_path) + + self.steam_vdf = vd.parse_vdf(self.steam_vdf_path) + self.steam_library_locations = vd.find_extra_locations(self.steam_vdf) + + for library in self.steam_library_locations: + print(f"[+] Found Steam library at: {library}") + + games = vd.fetchall_vdfs(self.steam_vdf) + games = self.sort_games(games) + + console = Console() + game_rend = [Panel(self.get_game_content(game), expand=True) for game in games] + console.print(Columns(game_rend)) + + self.prompter = PromptHelper(games) + self.prompt_user() + + + def prompt_user(self): + game = self.prompter.prompt_game(text="Enter a game (name | appid) to search for: ") + if game is None: + return + console = Console() + console.print(Panel(self.get_game_content(game), expand=True)) + + + def sort_games(self, games): + return sorted(games, key=lambda x: x['name']) + + def get_game_content(self, game): + """ + Takes a game dictionary and returns a string that formats game information into a string renderable by the console in the rich library. + + :param game: A dictionary containing information about a Steam game + :return: string formatted for table using rich library + """ + # Spaces must be replaced with %20 otherwise they won't link properly + + string = f"""[b]{game['name']}[/b] + [white]Game ID: [yellow]{game['appid']} + [white]Game Size: [red]{int(game['SizeOnDisk'])/(1024*1024)/1024:.2f} GB[/red] + [white]Game acf: [green][link=file://{game['acf_path'].replace(' ', '%20')}]file[/link][/green] + [white]Game Path: [green][link=file://{game['true_path'].replace(' ', '%20')}]dir[/link][/green]""" + if game['compatdata_path']: + string += f"\n\t[white]Compatdata dir: [blue][link=file://{game['compatdata_path'].replace(' ', '%20')}]dir[/link][/blue]" + return string +if __name__ == "__main__": + steam_path_tool = SteamGamePathTool() diff --git a/prompt_helper.py b/prompt_helper.py new file mode 100644 index 0000000..9edbe29 --- /dev/null +++ b/prompt_helper.py @@ -0,0 +1,29 @@ +import prompt_toolkit as pt +from prompt_toolkit.completion import WordCompleter, FuzzyCompleter + +def generate_completer(game_list): + g_list = [x['name'] for x in game_list] + [x['appid'] for x in game_list] + return FuzzyCompleter(WordCompleter(g_list)) + +class PromptHelper: + def __init__(self, game_list): + self.game_list = game_list + self.completer = generate_completer(game_list) + + def find_game_str(self, game_name): + for game in self.game_list: + if game['name'] == game_name: + return game + def find_game_num(self, appid: int): + for game in self.game_list: + if game['appid'] == appid: + return game + + def prompt_game(self, text, default="None"): + response = pt.prompt(text, completer=self.completer, complete_while_typing=True) + if response == "": + return None + elif response[0].isalpha(): + return self.find_game_str(response) + else: + return self.find_game_num(int(response)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..baab6a6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "steamgamepath" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "prompt-toolkit>=3.0.51", + "rich>=14.0.0", + "vdf>=3.4", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..06876da --- /dev/null +++ b/uv.lock @@ -0,0 +1,93 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + +[[package]] +name = "steamgamepath" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "prompt-toolkit" }, + { name = "rich" }, + { name = "vdf" }, +] + +[package.metadata] +requires-dist = [ + { name = "prompt-toolkit", specifier = ">=3.0.51" }, + { name = "rich", specifier = ">=14.0.0" }, + { name = "vdf", specifier = ">=3.4" }, +] + +[[package]] +name = "vdf" +version = "3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/7f/74192f47d67c8bf3c47bf0d8487b3457614c2c98d58b6617721d217f3f79/vdf-3.4.tar.gz", hash = "sha256:fd5419f41e07a1009e5ffd027c7dcbe43d1f7e8ef453aeaa90d9d04b807de2af", size = 11132 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/60/6456b687cf55cf60020dcd01f9bc51561c3cc84f05fd8e0feb71ce60f894/vdf-3.4-py2.py3-none-any.whl", hash = "sha256:68c1a125cc49e343d535af2dd25074e9cb0908c6607f073947c4a04bbe234534", size = 10357 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] diff --git a/vdfparser.py b/vdfparser.py new file mode 100644 index 0000000..7de1a2e --- /dev/null +++ b/vdfparser.py @@ -0,0 +1,105 @@ +import os +import vdf + +def parse_vdf(steam_vdf_path) -> dict: + """ + Reads the Steam vdf file at the given path and returns the corresponding dict. + + :param steam_vdf_path: Path to the Steam vdf file + :return: Dict representing the vdf data + """ + vdf_data = vdf.loads(open(steam_vdf_path, 'r').read()) + return vdf_data + +def fetch_ids(vdf_json: dict): + """ + Prints all Steam app IDs from the given vdf data. + + :param vdf_json: vdf data as a dict + """ + for library in vdf_json["libraryfolders"]: + print(f"Library {library}: {vdf_json['libraryfolders'][library]['apps']}") + for app_id in vdf_json['libraryfolders'][library]['apps']: + print(f"{app_id}") + print() + +def fetch_steam_path() -> str: + """ + Determines the Steam installation path by checking common locations. + + Returns the path to the Steam 'steamapps' directory. + Prefers the path used by the apt installation if it exists. + Otherwise, returns the path used by the Flatpak installation. + + :return: Path to the Steam 'steamapps' directory + """ + + apt_steam_path = os.path.expanduser(r"~/.steam/steam/steamapps/") + flatpak_steam_path = os.path.expanduser(r"~/.var/app/com.valvesoftware.Steam/data/Steam/steamapps/") + return apt_steam_path if os.path.exists(apt_steam_path) else flatpak_steam_path + +def fetch_steam_vdf() -> str | None: + """ + Determines the path to the Steam libraryfolders.vdf file by checking common locations. + + Returns the path to the libraryfolders.vdf file if it exists. + Otherwise, returns None. + + :return: Path to the libraryfolders.vdf file or None + """ + steam_vdf_path = os.path.join(fetch_steam_path(), "libraryfolders.vdf") + return steam_vdf_path if os.path.exists(steam_vdf_path) else None + +def validate_steam_path(path) -> bool: + if not os.path.exists(path): + print(f"Steam path does not exist (could be not mounted): {path}") + return False + return True + +# Scans vdf file for additional steam library paths... e.g. USB drive, external HDD +def find_extra_locations(vdf_json): + """ + Scans the vdf_json for additional Steam library paths and validates them. + + :param vdf_json: A dictionary representing the parsed Steam VDF data. + :return: A list of valid paths to additional Steam library locations. + """ + + steam_library_locations = [] + for library in vdf_json["libraryfolders"]: + path = vdf_json['libraryfolders'][library]['path'] + # print(path) + if validate_steam_path(path): + steam_library_locations.append(path) + else: + print(f"[-] Invalid Steam path: {path}") + return steam_library_locations + +# Reads manifest of individual game vdfs +def read_game_vdf(gameID: int, steam_vdf_json: dict, path, game) -> dict: + with open(os.path.join(path, game), 'r') as f: + game_vdf = vdf.loads(f.read()) + return game_vdf['AppState'] + +def fetchall_vdfs(steam_vdf_json: dict): + """ + Reads all Steam game manifests in the given Steam VDF data and returns a list of dictionaries containing information about each game. + + :param steam_vdf_json: A dictionary representing the parsed Steam VDF data. + :return: A list of dictionaries containing information about each Steam game. + """ + games = [] + for library in steam_vdf_json["libraryfolders"]: + path = steam_vdf_json['libraryfolders'][library]['path'] + steamapps = os.path.join(path, "steamapps") + for game in os.listdir(steamapps): + if game.endswith(".acf"): + gameID = int(game.split('.')[0].split('_')[1]) + parsed_game = read_game_vdf(gameID, steam_vdf_json, steamapps, game) + parsed_game['acf_path'] = os.path.join(steamapps, game) + parsed_game['root_steam_folder'] = path + parsed_game['true_path'] = os.path.join(steamapps, "common", parsed_game['installdir']) + parsed_game['compatdata_path'] = os.path.join(steamapps, "compatdata", str(gameID)) if os.path.exists(os.path.join(steamapps, "compatdata", str(gameID))) else None + games.append(parsed_game) + # print("Game name:", parsed_game['name'], "ID:", gameID, "Path:", parsed_game['true_path']) + return games