├── .gitattributes
├── .gitignore
├── README.md
├── license_parser.py
├── missing_cover_downloader.py
├── missingcoverdb.json
└── vdf.py
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
4 | # Custom for Visual Studio
5 | *.cs diff=csharp
6 |
7 | # Standard to msysgit
8 | *.doc diff=astextplain
9 | *.DOC diff=astextplain
10 | *.docx diff=astextplain
11 | *.DOCX diff=astextplain
12 | *.dot diff=astextplain
13 | *.DOT diff=astextplain
14 | *.pdf diff=astextplain
15 | *.PDF diff=astextplain
16 | *.rtf diff=astextplain
17 | *.RTF diff=astextplain
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Windows image file caches
2 | Thumbs.db
3 | ehthumbs.db
4 |
5 | # Folder config file
6 | Desktop.ini
7 |
8 | # Recycle Bin used on file shares
9 | $RECYCLE.BIN/
10 |
11 | # Windows Installer files
12 | *.cab
13 | *.msi
14 | *.msm
15 | *.msp
16 |
17 | # Windows shortcuts
18 | *.lnk
19 |
20 | # =========================
21 | # Operating System Files
22 | # =========================
23 |
24 | # OSX
25 | # =========================
26 |
27 | .DS_Store
28 | .AppleDouble
29 | .LSOverride
30 |
31 | # Thumbnails
32 | ._*
33 |
34 | # Files that might appear in the root of a volume
35 | .DocumentRevisions-V100
36 | .fseventsd
37 | .Spotlight-V100
38 | .TemporaryItems
39 | .Trashes
40 | .VolumeIcon.icns
41 |
42 | # Directories potentially created on remote AFP share
43 | .AppleDB
44 | .AppleDesktop
45 | Network Trash Folder
46 | Temporary Items
47 | .apdisk
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Steam Missing Cover Downloader
2 |
3 | Downloads missing portrait covers in your library for steam beta.
4 | Covers downloaded from steamgriddb.com
5 |
6 | ## Getting Started
7 |
8 | ### Prerequisites
9 |
10 | Python 3.7+
11 |
12 | Libraries:
13 |
14 | * aiohttp
15 | * [steam](https://github.com/ValvePython/steam)
16 |
17 | Install using the commands:
18 | ```
19 | pip install aiohttp
20 | pip install steam
21 | ```
22 |
23 | ### Running
24 |
25 | ```
26 | python missing_cover_downloader.py
27 | ```
28 |
29 | #### Command Line Options
30 | ```
31 | usage: missing_cover_downloader.py [-h] [-l] [-r] [-m MIN_SCORE] [-s STYLES]
32 | [-o] [-d]
33 |
34 | Downloads missing covers for new steam UI. Covers are downloaded from
35 | steamgriddb.com
36 |
37 | optional arguments:
38 | -h, --help show this help message and exit
39 | -l, --local Local mode, this is the default operation.
40 | -r, --remote Remote mode, if both local and remote are specified,
41 | will try local mode first.
42 | -m MIN_SCORE, --minscore MIN_SCORE
43 | Set min score for a cover to be downloaded.
44 | -s STYLES, --styles STYLES
45 | Set styles of cover, can be comma separated list of
46 | alternate, blurred, white_logo, material or no_logo.
47 | -o, --overwrite Overwrite covers that are already present in local
48 | steam grid path.
49 | -d, --delete-local Delete local covers for games that already have
50 | official ones.
51 | ```
52 |
53 |
54 |
55 | ## Troubleshooting
56 |
57 | | Error | Solution |
58 | | ------------------------------------------------------------ | ------------------------------------------------------------ |
59 | | `ModuleNotFoundError: No module named 'google'` | Check if `protobuf` Python library is installed via `pip list`, if not, run `pip install protobuf` |
60 | | `File "asyncio\base_events.py", line 508, in _check_closed`
`RuntimeError: Event loop is closed` | Too many images needed to download at once?
Try grabbing some images manually from `steamgriddb.com`, and placing them in `Steam\userdata\[user id]\config\grid`
Also try running `missing_cover_downloader.py` with the `-m` argument. Start at `20` and work down (so `missing_cover_downloader.py -m 20`, then `missing_cover_downloader.py -m 15`, etc.) |
61 | | `Cannot connect to host www.steamgriddb.com:443 ssl:default` | Your proxy settings may be preventing you from downloading images from steamgriddb.
In Windows, go to *Internet Options -> Connections -> LAN settings*.
Under *Automatic configuration*, check *Automatically detect settings*
Under *Proxy Server* uncheck *Use a proxy server for your LAN* |
62 |
63 | ## Update History
64 |
65 | 1.0.0
66 | * Initial release
67 |
68 | 1.2.0
69 | * Added support to read data from local appcache.
70 | * Fixed an issue that steamgriddb stopped returning correct covers
71 | * Added Mac support (Thanks to [UKMeng](https://github.com/UKMeng))
72 |
73 | 1.5.0
74 |
75 | * Significantly imporves performance using asychronous requests
76 | * Refactored code
77 | * Added Linux support (Thanks to [KrystianoXPL](https://github.com/KrystianoXPL))
78 | * Fixed a bug that some games in library are not returned.
79 | * Fixed a bug that games in appcache but not in game library are returned.
80 |
81 | 1.6.0
82 | * The script now uses SGDB API 2.3.0, which supports filtering by size. Scrapping the site is no longer needed.
83 | * Added support for switching between local and remote mode.
84 | * Added support to set the minimum score for a cover to be downloaded.
85 |
86 | 1.6.2
87 | * Added option to overwrite existing covers.
88 | * Added option to select cover styles.
89 | * Added option to delete custom covers when official covers are available.
90 |
--------------------------------------------------------------------------------
/license_parser.py:
--------------------------------------------------------------------------------
1 | from steam.protobufs.steammessages_clientserver_pb2 import CMsgClientLicenseList
2 |
3 | NTAB = 32
4 | IA = 16807
5 | IM = 2147483647
6 | IQ = 127773
7 | IR = 2836
8 | NDIV = (1+(IM-1)//NTAB)
9 | MAX_RANDOM_RANGE = 0x7FFFFFFF
10 | class RandomStream:
11 | def __init__(self):
12 | self.set_seed(0)
13 |
14 | def set_seed(self, iSeed):
15 | self.m_idum = iSeed if ( iSeed < 0 ) else -iSeed
16 | self.m_iy = 0
17 | self.m_iv = [0 for _ in range(NTAB)]
18 |
19 | def generate_random_number(self):
20 | if self.m_idum <= 0 or not self.m_iy:
21 | if -(self.m_idum) < 1:
22 | self.m_idum = 1
23 | else:
24 | self.m_idum = -(self.m_idum)
25 | for j in range(NTAB+7,-1,-1):
26 | k = (self.m_idum)//IQ
27 | self.m_idum = IA*(self.m_idum-k*IQ)-IR*k
28 | if self.m_idum < 0:
29 | self.m_idum += IM
30 | if j < NTAB:
31 | self.m_iv[j] = self.m_idum
32 | self.m_iy=self.m_iv[0]
33 |
34 | k=(self.m_idum)//IQ
35 | self.m_idum=IA*(self.m_idum-k*IQ)-IR*k
36 | if (self.m_idum < 0):
37 | self.m_idum += IM
38 | j=self.m_iy//NDIV
39 |
40 | if j >= NTAB or j < 0:
41 | j = ( j % NTAB ) & 0x7fffffff
42 |
43 | self.m_iy=self.m_iv[j]
44 | self.m_iv[j] = self.m_idum
45 |
46 | return self.m_iy
47 |
48 | def random_int(self, iLow, iHigh):
49 | x = iHigh-iLow+1
50 | if x <= 1 or MAX_RANDOM_RANGE < x-1:
51 | return iLow
52 |
53 | maxAcceptable = MAX_RANDOM_RANGE - ((MAX_RANDOM_RANGE+1) % x )
54 | while True:
55 | n = self.generate_random_number()
56 | if n <= maxAcceptable:
57 | break
58 |
59 | return iLow + (n % x)
60 |
61 | def random_char(self):
62 | return self.random_int(32,126)
63 |
64 | def decrypt_data(self, key, data):
65 | self.set_seed(key)
66 | result = bytearray(data)
67 | for i in range(len(data)):
68 | byte = self.random_char()
69 | #if i >= 0x6c5a0:
70 | # print(hex(byte))
71 | result[i] = data[i] ^ byte
72 | return result
73 |
74 | def parse(path, steamid):
75 | with open(path,'rb') as f:
76 | encrypted = f.read()
77 |
78 | random = RandomStream()
79 | decrypted = random.decrypt_data(steamid, encrypted)
80 |
81 | msg = CMsgClientLicenseList()
82 | msg.ParseFromString(bytes(decrypted[:-4]))
83 | return msg
84 |
85 |
--------------------------------------------------------------------------------
/missing_cover_downloader.py:
--------------------------------------------------------------------------------
1 | from steam.client import SteamClient
2 | from steam.steamid import SteamID
3 | from steam.webapi import WebAPI
4 | from steam.enums import EResult
5 | import sys, os, os.path
6 | import platform
7 | import re
8 | import json
9 | import urllib.request
10 | import struct
11 | import traceback
12 | import vdf
13 | import argparse
14 | import asyncio
15 | import aiohttp
16 | import license_parser
17 |
18 | OS_TYPE = platform.system()
19 | if OS_TYPE == "Windows":
20 | import winreg
21 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
22 | elif OS_TYPE == "Darwin" or OS_TYPE == "Linux":
23 | import ssl
24 | ssl._create_default_https_context = ssl._create_unverified_context
25 |
26 |
27 | SGDB_API_KEY = "e7732886b6c03a829fccb9c14fff2685"
28 | STEAM_CONFIG = "{}/config/config.vdf"
29 | STEAM_LOGINUSER = "{}/config/loginusers.vdf"
30 | STEAM_GRIDPATH = "{}/userdata/{}/config/grid"
31 | STEAM_APPINFO = "{}/appcache/appinfo.vdf"
32 | STEAM_PACKAGEINFO = "{}/appcache/packageinfo.vdf"
33 | STEAM_CLIENTCONFIG = "{}/userdata/{}/7/remote/sharedconfig.vdf"
34 | STEAM_USERCONFIG = "{}/userdata/{}/config/localconfig.vdf"
35 |
36 | def split_list(l,n):
37 | for i in range(0,len(l),n):
38 | yield l[i:i+n]
39 |
40 | def retry_func(func,errorhandler=print,retry=3):
41 | for _ in range(retry):
42 | try:
43 | rst = func()
44 | return rst,True
45 | except Exception as ex:
46 | errorhandler(ex)
47 | continue
48 | return None,False
49 |
50 |
51 | async def retry_func_async(func,errorhandler=print,retry=3):
52 | for _ in range(retry):
53 | try:
54 | rst = await func()
55 | return rst,True
56 | except Exception as ex:
57 | errorhandler(ex)
58 | continue
59 | return None,False
60 |
61 |
62 | def input_steamid():
63 | str = input("Enter steamid or profile url:")
64 | try:
65 | return SteamID(int(str))
66 | except ValueError:
67 | return SteamID.from_url(str)
68 |
69 | class SteamDataReader(object):
70 |
71 | @staticmethod
72 | def get_steam_installpath():
73 | if OS_TYPE == "Windows":
74 | key = winreg.OpenKey(
75 | winreg.HKEY_CURRENT_USER,
76 | r"SOFTWARE\Valve\Steam"
77 | )
78 | return winreg.QueryValueEx(key, "SteamPath")[0]
79 | elif OS_TYPE == "Darwin":
80 | return os.path.expandvars('$HOME') + "/Library/Application Support/Steam"
81 | elif OS_TYPE == "Linux":
82 | return os.path.expandvars('$HOME') + "/.steam/steam"
83 |
84 |
85 | def get_appids_from_packages(self,packages):
86 | rst = set()
87 | for _,pkg in packages.items():
88 | rst = rst | {appid for k,appid in pkg['appids'].items()}
89 | return list(rst)
90 |
91 |
92 | def get_missing_cover_dict_from_app_details(self,apps):
93 | rst = {}
94 | for appid,app in apps.items():
95 | if "common" in app and app["common"]["type"].lower() == "game" and not "library_assets" in app["common"]:
96 | rst[int(appid)] = app["common"]["name"]
97 | return rst
98 |
99 | def get_app_details(self,packages):
100 | return {}
101 |
102 | def get_package_details(self,apps):
103 | return {}
104 |
105 | def get_owned_packages(self):
106 | return []
107 |
108 | def get_missing_cover_app_dict(self,usedb=True):
109 |
110 | owned_packageids = self.get_owned_packages()
111 | print("Total packages in library:",len(owned_packageids))
112 | print("Retriving package details")
113 | owned_packages = self.get_package_details(owned_packageids)
114 | print("Retriving apps in packages")
115 | owned_appids = self.get_appids_from_packages(owned_packages)
116 | print("Total apps in library:",len(owned_appids))
117 | if usedb and os.path.exists("missingcoverdb.json"):
118 | with open("missingcoverdb.json",encoding="utf-8") as f:
119 | missing_cover_apps = {int(appid):value for appid,value in json.load(f).items()}
120 | print("Loaded database with {} apps missing covers".format(len(missing_cover_apps)))
121 | owned_appids = set(owned_appids)
122 | return {appid:value for appid,value in missing_cover_apps if appid in owned_appids}
123 | else:
124 | print("Retriving app details")
125 | owned_apps = self.get_app_details(owned_appids)
126 | return self.get_missing_cover_dict_from_app_details(owned_apps)
127 |
128 |
129 | class SteamDataReaderRemote(SteamDataReader):
130 |
131 | def __init__(self,client,request_batch=200):
132 | self.client = client
133 | self.request_batch = request_batch
134 |
135 | def get_steam_id(self):
136 | return self.client.steam_id
137 |
138 | def get_app_details(self,appids):
139 | rst = {}
140 | for i,sublist in enumerate(split_list(appids,self.request_batch)):
141 | print("Loading app details: {}-{}".format(i*self.request_batch+1,i*self.request_batch+len(sublist)))
142 | subrst, success = retry_func(lambda: self.client.get_product_info(sublist))
143 | if success:
144 | rst.update(subrst['apps'])
145 | return rst
146 |
147 | def get_package_details(self,pkgids):
148 | rst = {}
149 | for i,sublist in enumerate(split_list(pkgids,self.request_batch)):
150 | print("Loading package details: {}-{}".format(i*self.request_batch+1,i*self.request_batch+len(sublist)))
151 | subrst, success = retry_func(lambda: self.client.get_product_info([],sublist))
152 | if success:
153 | rst.update(subrst['packages'])
154 | return rst
155 |
156 | def get_owned_packages(self):
157 | timeout = 30
158 | for _ in range(timeout):
159 | if len(self.client.licenses) == 0:
160 | self.client.sleep(1)
161 | else:
162 | break
163 | return list(self.client.licenses.keys())
164 |
165 | class SteamDataReaderLocal(SteamDataReader):
166 |
167 | def __init__(self,steampath):
168 | self.steam_path = steampath
169 | self.appinfo = None
170 | self.packageinfo = None
171 |
172 | def get_steam_id(self):
173 | loginuser_path = STEAM_LOGINUSER.format(self.steam_path)
174 | if os.path.isfile(loginuser_path):
175 | with open(loginuser_path,'r',encoding='utf-8') as f:
176 | login_user = vdf.load(f)
177 | login_steamids = list(login_user['users'].keys())
178 | if len(login_steamids) == 1:
179 | return SteamID(int(login_steamids[0]))
180 | elif len(login_steamids) == 0:
181 | return SteamID()
182 | else:
183 | for id,value in login_user.items():
184 | if value.get("mostrecent") == 1:
185 | return int(id)
186 | return SteamID(int(login_steamids[0]))
187 |
188 | def get_app_details(self,appids):
189 | if not self.appinfo:
190 | print("Loading appinfo.vdf")
191 | self.appinfo = self.load_appinfo()
192 | print("Total apps in local cache",len(self.appinfo))
193 | return {appid:self.appinfo[appid] for appid in appids if appid in self.appinfo}
194 |
195 | def get_package_details(self,packageids):
196 | if not self.packageinfo:
197 | print("Loading packageinfo.vdf")
198 | self.packageinfo = self.load_packageinfo()
199 | print("Total packages in local cache",len(self.packageinfo))
200 | return {packageid:self.packageinfo[packageid] for packageid in packageids if packageid in self.packageinfo}
201 |
202 | def load_appinfo(self):
203 | appinfo_path = STEAM_APPINFO.format(self.steam_path)
204 | if not os.path.isfile(appinfo_path):
205 | raise FileNotFoundError("appinfo.vdf not found")
206 | with open(appinfo_path,"rb") as f:
207 | appinfo = vdf.appinfo_loads(f.read())
208 | return appinfo
209 |
210 | def load_packageinfo(self):
211 | package_info_path = STEAM_PACKAGEINFO.format(self.steam_path)
212 | if not os.path.isfile(package_info_path):
213 | raise FileNotFoundError("packageinfo.vdf not found")
214 | with open(package_info_path,"rb") as f:
215 | packageinfo = vdf.packageinfo_loads(f.read())
216 | return packageinfo
217 |
218 | def get_owned_packages(self):
219 | steamid32 = self.get_steam_id().as_32
220 | license_cache_path = f"{self.steam_path}/userdata/{steamid32}/config/licensecache"
221 | licenses = license_parser.parse(license_cache_path, steamid32).licenses
222 | #local_config_path = STEAM_USERCONFIG.format(self.steam_path,self.get_steam_id().as_32)
223 | #with open(local_config_path,'r',encoding='utf-8',errors='replace') as f:
224 | # local_config = vdf.load(f)
225 | return [package.package_id for package in licenses]
226 |
227 |
228 |
229 |
230 | async def query_cover_for_apps(appid,session,styles=None):
231 | url = "https://www.steamgriddb.com/api/v2/grids/steam/{}?dimensions=600x900".format(','.join(appid) if isinstance(appid, list) else appid)
232 | if styles:
233 | url = f'{url}&styles={styles}'
234 | jsondata = await fetch_url(url,session,'json',headers={"Authorization": "Bearer {}".format(SGDB_API_KEY)})
235 | if isinstance(appid, list) and jsondata['success']:
236 | jsondata['data'] = zip(appid, jsondata['data'])
237 | return jsondata
238 |
239 | async def query_sgdbid_for_appid(appid,session):
240 | url = "https://www.steamgriddb.com/api/v2/games/steam/{}".format(appid)
241 | jsondata = await fetch_url(url,session,'json',headers={"Authorization": "Bearer {}".format(SGDB_API_KEY)})
242 | return jsondata
243 |
244 | def quick_get_image_size(data):
245 | height = -1
246 | width = -1
247 |
248 | size = len(data)
249 | # handle PNGs
250 | if size >= 24 and data.startswith(b'\211PNG\r\n\032\n') and data[12:16] == b'IHDR':
251 | try:
252 | width, height = struct.unpack(">LL", data[16:24])
253 | except struct.error:
254 | raise ValueError("Invalid PNG file")
255 | # Maybe this is for an older PNG version.
256 | elif size >= 16 and data.startswith(b'\211PNG\r\n\032\n'):
257 | # Check to see if we have the right content type
258 | try:
259 | width, height = struct.unpack(">LL", data[8:16])
260 | except struct.error:
261 | raise ValueError("Invalid PNG file")
262 | # handle JPEGs
263 | elif size >= 2 and data.startswith(b'\377\330'):
264 | try:
265 | index = 0
266 | size = 2
267 | ftype = 0
268 | while not 0xc0 <= ftype <= 0xcf or ftype in [0xc4, 0xc8, 0xcc]:
269 | index+=size
270 | while data[index] == 0xff:
271 | index += 1
272 | ftype = data[index]
273 | index += 1
274 | size = struct.unpack('>H', data[index:index+2])[0]
275 | # We are at a SOFn block
276 | index+=3 # Skip `precision' byte.
277 | height, width = struct.unpack('>HH', data[index:index+4])
278 | except struct.error:
279 | raise ValueError("Invalid JPEG file")
280 | else:
281 | raise ValueError("Unsupported format")
282 |
283 | return width, height
284 |
285 |
286 |
287 | async def fetch_url(url, session:aiohttp.ClientSession,returntype='bin',**kwargs):
288 | resp = await session.get(url,**kwargs)
289 | resp.raise_for_status()
290 | if returntype == 'bin':
291 | return await resp.read()
292 | elif returntype == 'html':
293 | return await resp.text()
294 | elif returntype == 'json':
295 | return await resp.json()
296 | raise ValueError("Unsupported return type")
297 |
298 |
299 | async def download_image(url,gridpath,appid,session,retrycount=3):
300 | try:
301 | data, success = await retry_func_async(lambda:fetch_url(url,session,'bin'),
302 | lambda ex: print("Download error: {}, retry".format(ex)),retrycount)
303 | if not success:
304 | return False
305 | width, height = quick_get_image_size(data)
306 | if width == 600 and height == 900:
307 | filename = "{}p{}".format(appid,url[-4:])
308 | with open(os.path.join(gridpath,filename),"wb") as f:
309 | f.write(data)
310 | print("Saved to",filename)
311 | return True
312 | else:
313 | print("Image size incorrect:",width,height)
314 | except:
315 | traceback.print_exc()
316 |
317 | return False
318 |
319 |
320 | async def download_cover(appid,path,session,args,excludeid=-1,retrycount=3):
321 |
322 | try:
323 | rst = await query_cover_for_apps(appid,session,args.styles)
324 | except :
325 | print("Failed to retrive cover data")
326 | return False
327 | if rst["success"]:
328 | # sort by score
329 | covers = rst["data"]
330 | covers.sort(key=lambda x:x["score"],reverse=True)
331 | print("Found {} covers".format(len(covers)))
332 | for value in covers:
333 | if value["id"] == excludeid:
334 | continue
335 | print("Downloading cover {} by {}, url: {}".format(value["id"],value["author"]["name"],value["url"]))
336 | success = await download_image(value["url"],path,appid,session)
337 | if success:
338 | return True
339 | return False
340 |
341 | async def download_covers(appids,gridpath,namedict,args):
342 |
343 | batch_query_data = []
344 | query_size = 50
345 | tasks = []
346 | proxies = urllib.request.getproxies()
347 | result = {'total_downloaded':0}
348 | if 'http' in proxies:
349 | os.environ['HTTP_PROXY'] = proxies['http']
350 | os.environ['HTTPS_PROXY'] = proxies['http']
351 | async with aiohttp.ClientSession(trust_env=True) as session:
352 | for index,sublist in enumerate(split_list(appids,query_size)):
353 | sublist = [str(appid) for appid in sublist]
354 | print('Querying covers {}-{}'.format(index*query_size+1,index*query_size+len(sublist)))
355 | query_covers = lambda lst: lambda :query_cover_for_apps(lst,session,args.styles)
356 | tasks.append(asyncio.create_task(retry_func_async(query_covers(sublist))))
357 |
358 | rsts = await asyncio.gather(*tasks)
359 | for rst, success in rsts:
360 | if success and rst['success']:
361 | batch_query_data.extend(rst['data'])
362 | else:
363 | print("Failed to retrieve cover info")
364 | sys.exit(4)
365 | async def task(queue,downloadresult):
366 | while True:
367 | appid,queryresult = await queue.get()
368 | print("Found most voted cover for {} {} by {}".format(appid,namedict[appid],queryresult["author"]["name"]))
369 | print("Downloading cover {}, url: {}".format(queryresult["id"],queryresult["url"]))
370 | try:
371 | success = await download_image(queryresult['url'],gridpath,appid,session)
372 | if not success:
373 | print("Finding all covers for {} {}".format(appid,namedict[int(appid)]))
374 | success = await download_cover(appid,gridpath,queryresult['id'],args)
375 | if success:
376 | downloadresult['total_downloaded'] += 1
377 | except Exception as ex:
378 | print(ex)
379 | queue.task_done()
380 | tasks = []
381 | queue=asyncio.Queue()
382 |
383 | number_jobs = 0
384 |
385 | for appid,queryresult in batch_query_data:
386 | appid = int(appid)
387 | if not queryresult['success']:
388 | print("Error finding cover for {}, {}".format(appid,' '.join(queryresult['errors'])))
389 | elif len(queryresult['data']) == 0:
390 | print("No cover found for {} {}".format(appid,namedict[appid]))
391 | elif args.min_score!= None and queryresult['data'][0]['score'] < args.min_score:
392 | print("Most voted cover for {} {} has score of {} < {} , skipping.".format(appid,namedict[appid],queryresult['data'][0]['score'],args.min_score))
393 | else:
394 | number_jobs += 1
395 | queue.put_nowait((appid,queryresult['data'][0]))
396 |
397 | if number_jobs:
398 | print("Found {} covers, downloading...".format(number_jobs))
399 |
400 | consumers = [asyncio.create_task(task(queue,result)) for i in range(20)]
401 |
402 | await queue.join()
403 | for c in consumers:
404 | c.cancel()
405 | return result['total_downloaded']
406 |
407 |
408 | async def query_cover_for_app_html(appid,session):
409 | try:
410 | jsondata, success = await retry_func_async(lambda:query_sgdbid_for_appid(appid,session),
411 | lambda ex: print("Error getting sgdb id for {}: {}, retry".format(appid,ex)))
412 | if success and jsondata['success']:
413 | gameid=jsondata['data']['id']
414 | url = 'https://www.steamgriddb.com/game/{}'.format(gameid)
415 | html, success = await retry_func_async(lambda:fetch_url(url,session,'html'),
416 | lambda ex: print("Error getting html {}: {}, retry".format(url,ex)))
417 | if not success:
418 | print("Failed to retrive grids for {} frome steamgriddb",appid)
419 | return None, 0
420 | soup = BeautifulSoup(html)
421 | result = []
422 | grids = soup.select(".grid")
423 | for grid in grids:
424 | if len(grid.select("img.d600x900")) != 0:
425 | result.append(
426 | {
427 | 'id':int(grid['data-id']),
428 | 'url':grid.select('.dload')[0]['href'],
429 | 'score':0,
430 | 'author':grid.select('.details a')[0].text.strip()
431 | }
432 | )
433 | if len(result) == 0:
434 | return None,grids
435 | result.sort(key=lambda x:x["score"],reverse=True)
436 | return result[0],len(grids)
437 | except:
438 | pass
439 | return None,0
440 |
441 |
442 |
443 | async def download_covers_temp(appids,gridpath,namedict):
444 | from bs4 import BeautifulSoup
445 |
446 | queue=asyncio.Queue()
447 |
448 | proxies = urllib.request.getproxies()
449 | if 'http' in proxies:
450 | os.environ['HTTP_PROXY'] = proxies['http']
451 | os.environ['HTTPS_PROXY'] = proxies['http']
452 |
453 | async with aiohttp.ClientSession(trust_env=True) as session:
454 | async def get_url(sublist,queue):
455 | for appid in sublist:
456 | print("Finding cover for {} {}".format(appid,namedict[appid]))
457 | cover,total = await query_cover_for_app_html(appid,session)
458 | if not cover:
459 | print("No cover found for {} {}".format(appid,namedict[appid]))
460 | continue
461 |
462 | await queue.put((appid, cover, total, namedict[appid]))
463 |
464 | producers = [asyncio.create_task(get_url(sublist,queue)) for sublist in split_list(appids,len(appids)//20)]
465 |
466 | #use dict to pass by reference
467 | result = {'total_downloaded':0}
468 |
469 | async def download_img(queue,result):
470 | while True:
471 | appid, cover, total, name = await queue.get()
472 | print("Found {} covers for {} {}".format(total,appid,name))
473 | print("Downloading cover with highest score, id: {} score:{} by {}, url: {}".format(cover["id"],cover["score"],cover["author"],cover["url"]))
474 | success = await download_image(cover["url"],gridpath,appid,session)
475 | if success:
476 | result['total_downloaded'] += 1
477 | queue.task_done()
478 |
479 | consumers = [asyncio.create_task(download_img(queue,result)) for i in range(20)]
480 | await asyncio.gather(*producers)
481 | await queue.join()
482 | for c in consumers:
483 | c.cancel()
484 |
485 | return result['total_downloaded']
486 |
487 | def main():
488 |
489 | parser = argparse.ArgumentParser(description='Downloads missing covers for new steam UI. Covers are downloaded from steamgriddb.com')
490 | parser.add_argument('-p','--path', dest='steam_path', type=str, default=None,
491 | help='Set Steam installation path.')
492 | parser.add_argument('-l','--local', action='store_true', dest='local_mode',
493 | help='Local mode, this is the default operation.')
494 | parser.add_argument('-r','--remote', action='store_true', dest='remote_mode',
495 | help='Remote mode, if both local and remote are specified, will try local mode first.')
496 | parser.add_argument('-m','--minscore', dest='min_score', type=int, default=None,
497 | help='Set min score for a cover to be downloaded.')
498 | parser.add_argument('-s','--styles', dest='styles', type=str, default=None,
499 | help='Set styles of cover, can be comma separated list of alternate, blurred, white_logo, material or no_logo.')
500 | parser.add_argument('-o','--overwrite', action='store_true', dest='overwrite',
501 | help='Overwrite covers that are already present in local steam grid path.')
502 | parser.add_argument('-d','--delete-local', action='store_true', dest='delete_local',
503 | help='Delete local covers for games that already have official ones.')
504 |
505 | args = parser.parse_args()
506 |
507 | try:
508 | if args.steam_path:
509 | steam_path = args.steam_path
510 | else:
511 | steam_path = SteamDataReader.get_steam_installpath()
512 | except:
513 | print("Could not find steam install path")
514 | sys.exit(1)
515 | print("Steam path:",steam_path)
516 |
517 | local_mode = True
518 | remote_fallback = True
519 | if not args.local_mode and args.remote_mode:
520 | local_mode = False
521 | elif args.local_mode and not args.remote_mode:
522 | remote_fallback = False
523 |
524 | if local_mode:
525 | steam_data_reader = SteamDataReaderLocal(steam_path)
526 | try:
527 | steamid = steam_data_reader.get_steam_id()
528 | if not steamid.is_valid():
529 | steamid = SteamID(input_steamid())
530 | if not steamid.is_valid():
531 | print("Invalid steam id")
532 | sys.exit(2)
533 | print("SteamID:",steamid.as_32)
534 |
535 |
536 | except Exception as error:
537 | print(error)
538 | if remote_fallback:
539 | print("Switch to remote mode")
540 | local_mode = False
541 | else:
542 | sys.exit(2)
543 |
544 |
545 | if not local_mode:
546 | client = SteamClient()
547 | if client.cli_login() != EResult.OK:
548 | print("Login Error")
549 | sys.exit(3)
550 | else:
551 | print("Login Success")
552 |
553 | steam_data_reader = SteamDataReaderRemote(client)
554 |
555 | steamid = client.steam_id
556 | print("SteamID:",steamid.as_32)
557 |
558 | steam_grid_path = STEAM_GRIDPATH.format(steam_path,steamid.as_32)
559 | if not os.path.isdir(steam_grid_path):
560 | os.mkdir(steam_grid_path)
561 | print("Steam grid path:",steam_grid_path)
562 | missing_cover_app_dict = steam_data_reader.get_missing_cover_app_dict(not local_mode)
563 |
564 | print("Total games missing cover in library:",len(missing_cover_app_dict))
565 | local_cover_map = {int(file[:len(file)-5]):file for file in os.listdir(steam_grid_path) if re.match(r"^\d+p.(png|jpg)$",file)}
566 | local_cover_appids = set(local_cover_map.keys())
567 | print("Total local covers found:",len(local_cover_appids))
568 | local_missing_cover_appids = missing_cover_app_dict.keys() - local_cover_appids
569 | print("Total missing covers locally:",len(local_missing_cover_appids))
570 | if args.overwrite:
571 | local_missing_cover_appids = set(missing_cover_app_dict.keys())
572 |
573 | if args.delete_local:
574 | local_duplicate_cover_appids = local_cover_appids - missing_cover_app_dict.keys()
575 | print(f'Found {len(local_duplicate_cover_appids)} games already have official covers.')
576 | for appid in local_duplicate_cover_appids:
577 | path = os.path.join(steam_grid_path,local_cover_map[appid])
578 | print(f'Deleting file {path}')
579 | os.remove(path)
580 |
581 |
582 | print("Finding covers from steamgriddb.com")
583 | local_missing_cover_appids = list(local_missing_cover_appids)
584 | local_missing_cover_appids.sort()
585 |
586 | total_downloaded = asyncio.run(download_covers(local_missing_cover_appids,steam_grid_path,missing_cover_app_dict,args))
587 | print("Total cover downloaded:",total_downloaded)
588 |
589 |
590 | if __name__ == "__main__":
591 | main()
592 |
--------------------------------------------------------------------------------
/vdf.py:
--------------------------------------------------------------------------------
1 | """
2 | Module for deserializing/serializing to and from VDF
3 | """
4 | __version__ = "3.2"
5 | __author__ = "Rossen Georgiev"
6 |
7 | import re
8 | import sys
9 | import struct
10 | from binascii import crc32
11 | from io import StringIO as unicodeIO
12 | from collections import namedtuple
13 |
14 | # Py2 & Py3 compatibility
15 | if sys.version_info[0] >= 3:
16 | string_type = str
17 | int_type = int
18 | BOMS = '\ufffe\ufeff'
19 |
20 | def strip_bom(line):
21 | return line.lstrip(BOMS)
22 | else:
23 | from StringIO import StringIO as strIO
24 | string_type = basestring
25 | int_type = long
26 | BOMS = '\xef\xbb\xbf\xff\xfe\xfe\xff'
27 | BOMS_UNICODE = '\\ufffe\\ufeff'.decode('unicode-escape')
28 |
29 | def strip_bom(line):
30 | return line.lstrip(BOMS if isinstance(line, str) else BOMS_UNICODE)
31 |
32 | # string escaping
33 | _unescape_char_map = {
34 | r"\n": "\n",
35 | r"\t": "\t",
36 | r"\v": "\v",
37 | r"\b": "\b",
38 | r"\r": "\r",
39 | r"\f": "\f",
40 | r"\a": "\a",
41 | r"\\": "\\",
42 | r"\?": "?",
43 | r"\"": "\"",
44 | r"\'": "\'",
45 | }
46 | _escape_char_map = {v: k for k, v in _unescape_char_map.items()}
47 |
48 | def _re_escape_match(m):
49 | return _escape_char_map[m.group()]
50 |
51 | def _re_unescape_match(m):
52 | return _unescape_char_map[m.group()]
53 |
54 | def _escape(text):
55 | return re.sub(r"[\n\t\v\b\r\f\a\\\?\"']", _re_escape_match, text)
56 |
57 | def _unescape(text):
58 | return re.sub(r"(\\n|\\t|\\v|\\b|\\r|\\f|\\a|\\\\|\\\?|\\\"|\\')", _re_unescape_match, text)
59 |
60 | # parsing and dumping for KV1
61 | def parse(fp, mapper=dict, merge_duplicate_keys=True, escaped=True):
62 | """
63 | Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a VDF)
64 | to a Python object.
65 |
66 | ``mapper`` specifies the Python object used after deserializetion. ``dict` is
67 | used by default. Alternatively, ``collections.OrderedDict`` can be used if you
68 | wish to preserve key order. Or any object that acts like a ``dict``.
69 |
70 | ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the
71 | same key into one instead of overwriting. You can se this to ``False`` if you are
72 | using ``VDFDict`` and need to preserve the duplicates.
73 | """
74 | if not issubclass(mapper, dict):
75 | raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper))
76 | if not hasattr(fp, 'readline'):
77 | raise TypeError("Expected fp to be a file-like object supporting line iteration")
78 |
79 | stack = [mapper()]
80 | expect_bracket = False
81 |
82 | re_keyvalue = re.compile(r'^("(?P(?:\\.|[^\\"])+)"|(?P#?[a-z0-9\-\_\\\?]+))'
83 | r'([ \t]*('
84 | r'"(?P(?:\\.|[^\\"])*)(?P")?'
85 | r'|(?P[a-z0-9\-\_\\\?\*\.]+)'
86 | r'))?',
87 | flags=re.I)
88 |
89 | for idx, line in enumerate(fp):
90 | if idx == 0:
91 | line = strip_bom(line)
92 |
93 | line = line.lstrip()
94 |
95 | # skip empty and comment lines
96 | if line == "" or line[0] == '/':
97 | continue
98 |
99 | # one level deeper
100 | if line[0] == "{":
101 | expect_bracket = False
102 | continue
103 |
104 | if expect_bracket:
105 | raise SyntaxError("vdf.parse: expected openning bracket (line %d)" % (idx + 1))
106 |
107 | # one level back
108 | if line[0] == "}":
109 | if len(stack) > 1:
110 | stack.pop()
111 | continue
112 |
113 | raise SyntaxError("vdf.parse: one too many closing parenthasis (line %d)" % (idx + 1))
114 |
115 | # parse keyvalue pairs
116 | while True:
117 | match = re_keyvalue.match(line)
118 |
119 | if not match:
120 | try:
121 | line += next(fp)
122 | continue
123 | except StopIteration:
124 | raise SyntaxError("vdf.parse: unexpected EOF (open key quote?)")
125 |
126 | key = match.group('key') if match.group('qkey') is None else match.group('qkey')
127 | val = match.group('val') if match.group('qval') is None else match.group('qval')
128 |
129 | if escaped:
130 | key = _unescape(key)
131 |
132 | # we have a key with value in parenthesis, so we make a new dict obj (level deeper)
133 | if val is None:
134 | if merge_duplicate_keys and key in stack[-1]:
135 | _m = stack[-1][key]
136 | else:
137 | _m = mapper()
138 | stack[-1][key] = _m
139 |
140 | stack.append(_m)
141 | expect_bracket = True
142 |
143 | # we've matched a simple keyvalue pair, map it to the last dict obj in the stack
144 | else:
145 | # if the value is line consume one more line and try to match again,
146 | # until we get the KeyValue pair
147 | if match.group('vq_end') is None and match.group('qval') is not None:
148 | try:
149 | line += next(fp)
150 | continue
151 | except StopIteration:
152 | raise SyntaxError("vdf.parse: unexpected EOF (open value quote?)")
153 |
154 | stack[-1][key] = _unescape(val) if escaped else val
155 |
156 | # exit the loop
157 | break
158 |
159 | if len(stack) != 1:
160 | raise SyntaxError("vdf.parse: unclosed parenthasis or quotes (EOF)")
161 |
162 | return stack.pop()
163 |
164 |
165 | def loads(s, **kwargs):
166 | """
167 | Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a JSON
168 | document) to a Python object.
169 | """
170 | if not isinstance(s, string_type):
171 | raise TypeError("Expected s to be a str, got %s" % type(s))
172 |
173 | try:
174 | fp = unicodeIO(s)
175 | except TypeError:
176 | fp = strIO(s)
177 |
178 | return parse(fp, **kwargs)
179 |
180 |
181 | def load(fp, **kwargs):
182 | """
183 | Deserialize ``fp`` (a ``.readline()``-supporting file-like object containing
184 | a JSON document) to a Python object.
185 | """
186 | return parse(fp, **kwargs)
187 |
188 |
189 | def dumps(obj, pretty=False, escaped=True):
190 | """
191 | Serialize ``obj`` to a VDF formatted ``str``.
192 | """
193 | if not isinstance(obj, dict):
194 | raise TypeError("Expected data to be an instance of``dict``")
195 | if not isinstance(pretty, bool):
196 | raise TypeError("Expected pretty to be of type bool")
197 | if not isinstance(escaped, bool):
198 | raise TypeError("Expected escaped to be of type bool")
199 |
200 | return ''.join(_dump_gen(obj, pretty, escaped))
201 |
202 |
203 | def dump(obj, fp, pretty=False, escaped=True):
204 | """
205 | Serialize ``obj`` as a VDF formatted stream to ``fp`` (a
206 | ``.write()``-supporting file-like object).
207 | """
208 | if not isinstance(obj, dict):
209 | raise TypeError("Expected data to be an instance of``dict``")
210 | if not hasattr(fp, 'write'):
211 | raise TypeError("Expected fp to have write() method")
212 | if not isinstance(pretty, bool):
213 | raise TypeError("Expected pretty to be of type bool")
214 | if not isinstance(escaped, bool):
215 | raise TypeError("Expected escaped to be of type bool")
216 |
217 | for chunk in _dump_gen(obj, pretty, escaped):
218 | fp.write(chunk)
219 |
220 |
221 | def _dump_gen(data, pretty=False, escaped=True, level=0):
222 | indent = "\t"
223 | line_indent = ""
224 |
225 | if pretty:
226 | line_indent = indent * level
227 |
228 | for key, value in data.items():
229 | if escaped and isinstance(key, string_type):
230 | key = _escape(key)
231 |
232 | if isinstance(value, dict):
233 | yield '%s"%s"\n%s{\n' % (line_indent, key, line_indent)
234 | for chunk in _dump_gen(value, pretty, escaped, level+1):
235 | yield chunk
236 | yield "%s}\n" % line_indent
237 | else:
238 | if escaped and isinstance(value, string_type):
239 | value = _escape(value)
240 |
241 | yield '%s"%s" "%s"\n' % (line_indent, key, value)
242 |
243 |
244 | # binary VDF
245 | class BASE_INT(int_type):
246 | def __repr__(self):
247 | return "%s(%d)" % (self.__class__.__name__, self)
248 |
249 | class UINT_64(BASE_INT):
250 | pass
251 |
252 | class INT_64(BASE_INT):
253 | pass
254 |
255 | class POINTER(BASE_INT):
256 | pass
257 |
258 | class COLOR(BASE_INT):
259 | pass
260 |
261 | BIN_NONE = b'\x00'
262 | BIN_STRING = b'\x01'
263 | BIN_INT32 = b'\x02'
264 | BIN_FLOAT32 = b'\x03'
265 | BIN_POINTER = b'\x04'
266 | BIN_WIDESTRING = b'\x05'
267 | BIN_COLOR = b'\x06'
268 | BIN_UINT64 = b'\x07'
269 | BIN_END = b'\x08'
270 | BIN_INT64 = b'\x0A'
271 | BIN_END_ALT = b'\x0B'
272 |
273 | def binary_loads(s, mapper=dict, merge_duplicate_keys=True, alt_format=False):
274 | result, idx = binary_loads_at(s,0,mapper,merge_duplicate_keys,alt_format)
275 |
276 | if len(s) != idx:
277 | raise SyntaxError("Binary VDF ended at index %d, but length is %d" % (idx, len(s)))
278 |
279 | return result
280 |
281 |
282 | def binary_loads_at(s, idx=0, mapper=dict, merge_duplicate_keys=True, alt_format=False):
283 | """
284 | Deserialize ``s`` (``bytes`` containing a VDF in "binary form")
285 | to a Python object.
286 |
287 | ``mapper`` specifies the Python object used after deserializetion. ``dict` is
288 | used by default. Alternatively, ``collections.OrderedDict`` can be used if you
289 | wish to preserve key order. Or any object that acts like a ``dict``.
290 |
291 | ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the
292 | same key into one instead of overwriting. You can se this to ``False`` if you are
293 | using ``VDFDict`` and need to preserve the duplicates.
294 | """
295 | if not isinstance(s, bytes):
296 | raise TypeError("Expected s to be bytes, got %s" % type(s))
297 | if not issubclass(mapper, dict):
298 | raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper))
299 |
300 | # helpers
301 | int32 = struct.Struct(' idx:
333 | t = s[idx:idx+1]
334 | idx += 1
335 |
336 | if t == CURRENT_BIN_END:
337 | if len(stack) > 1:
338 | stack.pop()
339 | continue
340 | break
341 |
342 | key, idx = read_string(s, idx)
343 |
344 | if t == BIN_NONE:
345 | if merge_duplicate_keys and key in stack[-1]:
346 | _m = stack[-1][key]
347 | else:
348 | _m = mapper()
349 | stack[-1][key] = _m
350 | stack.append(_m)
351 | elif t == BIN_STRING:
352 | stack[-1][key], idx = read_string(s, idx)
353 | elif t == BIN_WIDESTRING:
354 | stack[-1][key], idx = read_string(s, idx, wide=True)
355 | elif t in (BIN_INT32, BIN_POINTER, BIN_COLOR):
356 | val = int32.unpack_from(s, idx)[0]
357 |
358 | if t == BIN_POINTER:
359 | val = POINTER(val)
360 | elif t == BIN_COLOR:
361 | val = COLOR(val)
362 |
363 | stack[-1][key] = val
364 | idx += int32.size
365 | elif t == BIN_UINT64:
366 | stack[-1][key] = UINT_64(uint64.unpack_from(s, idx)[0])
367 | idx += uint64.size
368 | elif t == BIN_INT64:
369 | stack[-1][key] = INT_64(int64.unpack_from(s, idx)[0])
370 | idx += int64.size
371 | elif t == BIN_FLOAT32:
372 | stack[-1][key] = float32.unpack_from(s, idx)[0]
373 | idx += float32.size
374 | else:
375 | raise SyntaxError("Unknown data type at index %d: %s" % (idx-1, repr(t)))
376 |
377 | if len(stack) != 1:
378 | raise SyntaxError("Binary VDF ended at index %d, but stack is not empty." % (idx))
379 |
380 | return stack.pop(), idx
381 |
382 | def binary_dumps(obj, alt_format=False):
383 | """
384 | Serialize ``obj`` to a binary VDF formatted ``bytes``.
385 | """
386 | return b''.join(_binary_dump_gen(obj, alt_format=alt_format))
387 |
388 | def _binary_dump_gen(obj, level=0, alt_format=False):
389 | if level == 0 and len(obj) == 0:
390 | return
391 |
392 | int32 = struct.Struct('