1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
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()))
|