This commit is contained in:
LunaChocken
2025-06-07 16:42:31 +01:00
commit 7db63e2c7d
8 changed files with 313 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__
.venv
+1
View File
@@ -0,0 +1 @@
3.11
+6
View File
@@ -0,0 +1,6 @@
[![asciicast](https://asciinema.org/a/qfN1Nc4Sy7swPJVXUeWsmSDqx.svg)](https://asciinema.org/a/qfN1Nc4Sy7swPJVXUeWsmSDqx)
+66
View File
@@ -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()
+29
View File
@@ -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))
+11
View File
@@ -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",
]
Generated
+93
View File
@@ -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 },
]
+105
View File
@@ -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