diff options
Diffstat (limited to 'games/games.py')
| -rwxr-xr-x | games/games.py | 190 |
1 files changed, 190 insertions, 0 deletions
diff --git a/games/games.py b/games/games.py new file mode 100755 index 0000000..eaf7d33 --- /dev/null +++ b/games/games.py @@ -0,0 +1,190 @@ +#!/usr/bin/python3 +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# 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 General Public License for more details. + +"""The Steam library as JSON: installed games, non-Steam shortcuts, and the +owned games that are not installed. + + games.py print the list (owned games from the cache) + games.py --refresh fetch the owned games from the Steam Web API + games.py --check run the self-check + +Installed games and shortcuts are read from Steam's own files on every run. +Owned games need the Web API, so they come from a cache that --refresh +rewrites; the key is read from `pass` (GAMES_PASS_ENTRY). +""" + +import json +import os +import re +import subprocess +import sys +import urllib.request +from pathlib import Path + +STEAM = Path(os.environ.get("GAMES_STEAM", "~/.local/share/Steam")).expanduser() +CACHE = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "keystone/games" +OWNED = CACHE / "owned.json" +PASS_ENTRY = os.environ.get("GAMES_PASS_ENTRY", "store.steampowered.com/qs-launcher") +API = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/" +# Installed like games, but runtimes Steam pulls in on its own. +TOOLS = re.compile(r"^(Proton\b|Steam Linux Runtime|Steamworks Common)") +STEAMID_BASE = 76561197960265728 + + +def kv(text): + """The "key" "value" pairs of a text VDF, flattened. Enough for an + appmanifest, whose wanted keys are all unique.""" + return dict(re.findall(r'"([^"]+)"\s+"([^"]*)"', text)) + + +def binvdf(data, i=0): + """Parse a binary VDF map starting at i; return (dict, next index). + Keys are lowercased: Steam has written both `appid` and `AppName`.""" + out = {} + while True: + t = data[i] + i += 1 + if t == 8: + return out, i + end = data.index(b"\0", i) + key = data[i:end].decode(errors="replace").lower() + i = end + 1 + if t == 0: + out[key], i = binvdf(data, i) + elif t == 1: + end = data.index(b"\0", i) + out[key] = data[i:end].decode(errors="replace") + i = end + 1 + elif t == 2: + out[key] = int.from_bytes(data[i:i + 4], "little") + i += 4 + else: + raise ValueError(f"unknown binary VDF type {t}") + + +def shortcut_gameid(appid): + """The id steam://rungameid/ takes for a non-Steam shortcut.""" + return str((appid & 0xFFFFFFFF) << 32 | 0x02000000) + + +def user(): + """The Steam account's userdata dir: the most recently touched one.""" + dirs = [d for d in (STEAM / "userdata").glob("*") if d.name.isdigit() and d.name != "0"] + return max(dirs, key=lambda d: d.stat().st_mtime) if dirs else None + + +def art(appid): + """The portrait cover and the wide hero image from Steam's library + cache. Older entries keep them at the top of the app's directory, newer + ones in hashed subdirectories, and the cover there is library_capsule.""" + d = STEAM / "appcache/librarycache" / str(appid) + found = {} + for key, names in (("cover", ("library_600x900*.jpg", "library_capsule*.jpg")), + ("hero", ("library_hero.jpg",))): + hits = [h for n in names for h in sorted(d.glob(f"**/{n}"))] + found[key] = str(hits[0]) if hits else "" + return found + + +def installed(): + libs = re.findall(r'"path"\s+"([^"]+)"', (STEAM / "steamapps/libraryfolders.vdf").read_text()) + out = {} + for lib in libs: + for acf in Path(lib, "steamapps").glob("appmanifest_*.acf"): + m = kv(acf.read_text(errors="replace")) + name = m.get("name", "").strip() + if name and not TOOLS.match(name): + out[m["appid"]] = {"name": name, "last": int(m.get("LastPlayed", 0))} + return out + + +def shortcuts(u): + path = u / "config/shortcuts.vdf" if u else None + if not path or not path.exists(): + return [] + try: + root, _ = binvdf(path.read_bytes()) + except (ValueError, IndexError): + return [] + out = [] + for s in root.get("shortcuts", {}).values(): + # Custom artwork set in Steam: <appid>p for the cover, <appid>_hero. + grid = lambda suffix: next(iter((u / "config/grid").glob(f"{s.get('appid', 0)}{suffix}.*")), "") + out.append({"name": s.get("appname", "?"), "id": shortcut_gameid(s.get("appid", 0)), + "appid": "", "installed": True, "cover": str(grid("p")), + "hero": str(grid("_hero")), "last": s.get("lastplaytime", 0)}) + return out + + +def games(): + u = user() + lib = installed() + out = [{"name": g["name"], "id": a, "appid": a, "installed": True, + "last": g["last"], **art(a)} for a, g in lib.items()] + try: + owned = json.loads(OWNED.read_text()) + except (OSError, ValueError): + owned = [] + for g in owned: + a = str(g["appid"]) + if a not in lib: + out.append({"name": g["name"], "id": a, "appid": a, "installed": False, + "last": g.get("rtime_last_played", 0), **art(a)}) + out += shortcuts(u) + # Installed first, most recently played first, then by name. + out.sort(key=lambda g: (not g["installed"], -g["last"], g["name"].lower())) + return out + + +def refresh(): + u = user() + if not u: + sys.exit("no Steam account in userdata") + key = subprocess.run(["pass", "show", PASS_ENTRY], capture_output=True, text=True, + timeout=30, check=True).stdout.split("\n")[0] + url = (f"{API}?key={key}&steamid={STEAMID_BASE + int(u.name)}" + "&include_appinfo=1&include_played_free_games=1") + with urllib.request.urlopen(url, timeout=15) as r: + owned = json.load(r)["response"].get("games", []) + CACHE.mkdir(parents=True, exist_ok=True) + tmp = OWNED.with_suffix(".tmp") + tmp.write_text(json.dumps(owned)) + tmp.replace(OWNED) + print(f"{len(owned)} owned") + + +def check(): + data = (b"\x00shortcuts\x00" + b"\x000\x00" + b"\x02appid\x00" + (0xE716847D).to_bytes(4, "little") + + b"\x01AppName\x00Example Game\x00" + b"\x00tags\x00\x08" + b"\x08\x08\x08") + root, end = binvdf(data) + assert end == len(data), end + s = root["shortcuts"]["0"] + assert s == {"appid": 0xE716847D, "appname": "Example Game", "tags": {}}, s + assert shortcut_gameid(0xE716847D) == str(0xE716847D << 32 | 0x02000000) + # Signed and unsigned readings of the same id give the same game id. + assert shortcut_gameid(0xE716847D - (1 << 32)) == shortcut_gameid(0xE716847D) + assert kv('"AppState"\n{\n\t"appid"\t\t"42"\n\t"name"\t\t"X "\n}') == {"appid": "42", "name": "X "} + assert TOOLS.match("Proton 9.0") and not TOOLS.match("Protonaut") + print("ok") + + +if __name__ == "__main__": + if "--check" in sys.argv: + check() + elif "--refresh" in sys.argv: + refresh() + else: + print(json.dumps(games())) |
