#!/usr/bin/python3 # Copyright (C) 2026 Danilo M. # # 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: p for the cover, _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()))