Skip to content

Commit b126b3a

Browse files
authored
Merge pull request #36 from aanderse/wip
version `0.10.0`
2 parents 502b92d + dc770d5 commit b126b3a

File tree

12 files changed

+1663
-195
lines changed

12 files changed

+1663
-195
lines changed

addon.xml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2-
<addon id="plugin.program.steam.library" name="Steam Library" version="0.9.0" provider-name="aanderse">
2+
<addon id="plugin.program.steam.library" name="Steam Library" version="0.10.0" provider-name="aanderse">
33
<requires>
44
<import addon="xbmc.python" version="3.0.0" />
55
<import addon="script.module.requests" version="2.22.0" />
@@ -9,6 +9,7 @@
99
<extension point="xbmc.python.pluginsource" library="addon.py">
1010
<provides>game executable</provides>
1111
</extension>
12+
<extension point="xbmc.service" library="service.py" start="login" />
1213
<extension point="xbmc.addon.metadata">
1314
<summary lang="en">Access your Steam library from Kodi</summary>
1415
<description lang="en">With this addon you can view your entire Steam library right from Kodi.[CR]This addon requires that you have a Steam account, have the Steam application installed, know your 17 digit Steam ID, and create a Steam API key.[CR][CR]To find your 17 digit Steam ID log into https://steamcommunity.com/, click on your username in the top right corner, and select view profile. Your 17 digit Steam ID will be in your web browsers address bar as the last 17 digits of the url.[CR][CR]To create a Steam API key log into https://steamcommunity.com/dev/apikey and create one. You could use "localhost" for the domain when prompted.</description>
@@ -47,6 +48,13 @@
4748
- ported to kodi 19.x
4849
v0.9.0 (2023-06-05)
4950
- ported to kodi 20.x
51+
v0.10.0 (2025-12-26)
52+
- added game metadata (descriptions, genres, ratings) via background service
53+
- improved artwork loading with parallel requests and fallback support
54+
- fixed installed games detection for modern Steam (libraryfolders.vdf)
55+
- fixed credential validation and version upgrade bugs
56+
- updated settings to Kodi 19+ format
57+
- security: masked credentials in debug logs
5058
</news>
5159
<assets>
5260
<icon>icon.png</icon>

resources/arts.py

Lines changed: 162 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,195 @@
1-
import xbmcaddon
2-
import xbmcvfs
3-
41
import os
2+
import time
3+
from concurrent.futures import ThreadPoolExecutor, as_completed
54
from datetime import timedelta
5+
66
import requests
77
import requests_cache
8+
import xbmcaddon
9+
import xbmcvfs
810

911
from .util import log
1012

1113
__addon__ = xbmcaddon.Addon()
12-
artFallbackEnabled = __addon__.getSetting("enable-art-fallback") == 'true' # Kodi stores boolean settings as strings
13-
monthsBeforeArtsExpiration = int(__addon__.getSetting("arts-expire-after-months")) # Default is 2 months
1414

15-
# define the cache file to reside in the ..\Kodi\userdata\addon_data\(your addon)
15+
# Cache setup for HEAD requests
1616
addonUserDataFolder = xbmcvfs.translatePath(__addon__.getAddonInfo('profile'))
17-
ART_AVAILABILITY_CACHE_FILE = xbmcvfs.translatePath(os.path.join(addonUserDataFolder, 'requests_cache_arts'))
18-
19-
cached_requests = requests_cache.core.CachedSession(ART_AVAILABILITY_CACHE_FILE, backend='sqlite',
20-
expire_after= timedelta(weeks=4*monthsBeforeArtsExpiration),
21-
allowable_methods=('HEAD',), allowable_codes=(200, 404),
22-
old_data_on_error=True,
23-
fast_save=True)
24-
# Existing Steam art types urls, to format to format with appid / img_icon_path
25-
STEAM_ARTS_TYPES = { # img_icon_path is provided by steam API to get the icon. https://developer.valvesoftware.com/wiki/Steam_Web_API#GetOwnedGames_.28v0001.29
26-
'poster': 'http://cdn.akamai.steamstatic.com/steam/apps/{appid}/library_600x900.jpg', # Can return 404
27-
'hero': 'http://cdn.akamai.steamstatic.com/steam/apps/{appid}/library_hero.jpg', # Can return 404
28-
'header': 'http://cdn.akamai.steamstatic.com/steam/apps/{appid}/header.jpg',
29-
'generated_bg': 'http://cdn.akamai.steamstatic.com/steam/apps/{appid}/page_bg_generated_v6b.jpg', # Auto generated background with a shade of blue.
17+
ART_CACHE_FILE = xbmcvfs.translatePath(os.path.join(addonUserDataFolder, 'requests_cache_arts'))
18+
19+
# Cache HEAD request results for 2 months
20+
cached_session = requests_cache.CachedSession(
21+
ART_CACHE_FILE,
22+
backend='sqlite',
23+
expire_after=timedelta(weeks=8),
24+
allowable_methods=('HEAD',),
25+
allowable_codes=(200, 404),
26+
old_data_on_error=True
27+
)
28+
29+
# Steam art URL templates
30+
STEAM_ARTS = {
31+
'poster': 'https://cdn.akamai.steamstatic.com/steam/apps/{appid}/library_600x900.jpg',
32+
'hero': 'https://cdn.akamai.steamstatic.com/steam/apps/{appid}/library_hero.jpg',
33+
'header': 'https://cdn.akamai.steamstatic.com/steam/apps/{appid}/header.jpg',
34+
'logo': 'https://cdn.akamai.steamstatic.com/steam/apps/{appid}/logo.png',
3035
'icon': 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/{appid}/{img_icon_path}.jpg',
31-
'clearlogo': 'http://cdn.akamai.steamstatic.com/steam/apps/{appid}/logo.png' # Can return 404
3236
}
3337

34-
# Dictionary containing for each art type, a url for the art (to format with appid / img_icon_path afterwards), and a fallback art type.
35-
# Having no fallback also means that the art url won't be tested
36-
ARTS_ASSIGNMENTS = {
37-
'poster': {'url': STEAM_ARTS_TYPES['poster'], 'fallback': 'landscape'},
38-
'banner': {'url': STEAM_ARTS_TYPES['hero'], 'fallback': 'landscape'},
39-
'fanart': {'url': STEAM_ARTS_TYPES['hero'], 'fallback': 'fanart1'},
40-
'fanart1': {'url': STEAM_ARTS_TYPES['header'], 'fallback': None},
41-
'fanart2': {'url': STEAM_ARTS_TYPES['generated_bg'], 'fallback': None}, # Multiple fanart https://kodi.wiki/view/Artwork_types#fanart.23
42-
'landscape': {'url': STEAM_ARTS_TYPES['header'], 'fallback': None},
43-
'thumb': {'url': STEAM_ARTS_TYPES['header'], 'fallback': None},
44-
'icon': {'url': STEAM_ARTS_TYPES['icon'], 'fallback': None},
45-
'clearlogo': {'url': STEAM_ARTS_TYPES['clearlogo'], 'fallback': None}
38+
# Map Kodi art types to Steam art (primary -> fallback)
39+
# header.jpg is available for all games, so it's the ultimate fallback
40+
ART_TYPE_CONFIG = {
41+
'poster': {'primary': 'poster', 'fallback': 'header'},
42+
'banner': {'primary': 'hero', 'fallback': 'header'},
43+
'fanart': {'primary': 'hero', 'fallback': 'header'},
44+
'fanart1': {'primary': 'header', 'fallback': None},
45+
'fanart2': {'primary': 'header', 'fallback': None},
46+
'landscape': {'primary': 'header', 'fallback': None},
47+
'thumb': {'primary': 'header', 'fallback': None},
48+
'icon': {'primary': 'icon', 'fallback': None},
49+
'clearlogo': {'primary': 'logo', 'fallback': None},
4650
}
4751

4852

49-
def is_art_url_available(url, timeout=2):
53+
def check_url_exists(url, timeout=2):
5054
"""
51-
Sends a HEAD request to check if an online resource is available. Uses a cache mechanism to speed things up or serve offline if a connection is unavailable.
52-
53-
:param url: url to check availability
54-
:param timeout: timeout of the request in seconds. Default is 2
55-
:return: boolean False if the status code is between 400&600 , True otherwise
55+
Check if a URL exists (returns 200). Results are cached.
5656
"""
57-
result = False
5857
try:
59-
response = cached_requests.head(url, timeout=timeout)
60-
if not 400 <= response.status_code < 600: # We consider valid any status codes below 400 or above 600
61-
result = True
62-
except IOError:
63-
result = False
64-
return result
58+
response = cached_session.head(url, timeout=timeout)
59+
return response.status_code == 200
60+
except:
61+
return False
62+
63+
64+
def get_art_url(art_key, appid, img_icon_path=''):
65+
"""Get a formatted Steam art URL."""
66+
template = STEAM_ARTS.get(art_key)
67+
if not template:
68+
return None
69+
return template.format(appid=appid, img_icon_path=img_icon_path)
6570

6671

67-
def resolve_art_url(art_type, appid, img_icon_path='', art_fallback_enabled=artFallbackEnabled):
72+
def resolve_art_for_game(appid, img_icon_path=''):
6873
"""
69-
Resolve the art url of a specified game/app, for a given art type defined in the :const:`ARTS_DATA` dictionary.
70-
Handles fallback to another art type if needed (ie the requested one is unavailable and fallback is enabled).
71-
72-
:param art_type: a valid art type, defined in :const:`ARTS_DATA`
73-
:param appid: appid of the game/app we want to get the art for.
74-
:param img_icon_path: A path provided by steam to get the icon art url. https://developer.valvesoftware.com/wiki/Steam_Web_API#GetOwnedGames_.28v0001.29
75-
:param art_fallback_enabled: Whether to fall back to another art type if an art is unavailable. Defaults to the user addon settings, which default to true
76-
:return: resolved art URL. Can be the URL of another available art if .
74+
Resolve all art URLs for a single game, checking availability and using fallbacks.
75+
Returns a dict of {art_type: url}.
7776
"""
78-
valid_art_url = None
79-
requested_art = ARTS_ASSIGNMENTS.get(art_type, None)
77+
art_dict = {}
78+
urls_to_check = []
8079

81-
while valid_art_url is None and requested_art is not None: # If the current media type is defined and we did not find a valid url yet
82-
art_url = requested_art.get('url').format(appid=appid, img_icon_path=img_icon_path) # We replace "{appid}" and "{img_icon_path}" in the url
83-
fallback_art_type = requested_art.get("fallback", None)
84-
if (not art_fallback_enabled) or (fallback_art_type is None) or is_art_url_available(art_url):
85-
# If art fallback is disabled, or if there is no fallback defined, we directly assume the art url as valid.
86-
# Otherwise, if art fallback is enabled and there is a fallback defined, we check if is_art_url_available before proceeding
87-
valid_art_url = art_url
88-
else: # If art fallback is enabled and art is not available, we set the current art data to the defined fallback, before retrying.
89-
requested_art = ARTS_ASSIGNMENTS.get(fallback_art_type, None) # Art data will be None if the fallback_art_type does not exist in the art_urls dict
80+
# Build list of primary URLs that need checking (have fallbacks)
81+
for art_type, config in ART_TYPE_CONFIG.items():
82+
primary_url = get_art_url(config['primary'], appid, img_icon_path)
83+
fallback_key = config.get('fallback')
9084

91-
if valid_art_url is None: # If the previous loop could not find a valid media url among the defined art types
92-
log("Issue resolving media {0} for app id {1}".format(art_type, appid))
85+
if fallback_key:
86+
# This art type has a fallback, so we need to check if primary exists
87+
urls_to_check.append((art_type, primary_url, fallback_key))
88+
else:
89+
# No fallback, just use the primary URL directly
90+
art_dict[art_type] = primary_url
9391

94-
return valid_art_url
92+
# Check all primary URLs that have fallbacks
93+
for art_type, primary_url, fallback_key in urls_to_check:
94+
if check_url_exists(primary_url):
95+
art_dict[art_type] = primary_url
96+
else:
97+
art_dict[art_type] = get_art_url(fallback_key, appid, img_icon_path)
9598

99+
return art_dict
96100

97-
def delete_cache():
101+
102+
def resolve_art_for_all_games(games):
103+
"""
104+
Resolve art URLs for all games in parallel.
105+
106+
:param games: list of game dicts with 'appid' and 'img_icon_url' keys
107+
:return: dict mapping appid -> art_dict
108+
"""
109+
start_time = time.time()
110+
111+
# First, collect all URLs that need checking
112+
urls_to_check = set()
113+
game_art_info = [] # (appid, img_icon_path, [(art_type, primary_url, fallback_key), ...])
114+
115+
for game in games:
116+
appid = str(game['appid'])
117+
img_icon_path = game.get('img_icon_url', '')
118+
119+
art_checks = []
120+
for art_type, config in ART_TYPE_CONFIG.items():
121+
if config.get('fallback'):
122+
primary_url = get_art_url(config['primary'], appid, img_icon_path)
123+
urls_to_check.add(primary_url)
124+
art_checks.append((art_type, primary_url, config['fallback']))
125+
126+
game_art_info.append((appid, img_icon_path, art_checks))
127+
128+
# Check all URLs in parallel
129+
url_exists = {}
130+
check_start = time.time()
131+
132+
with ThreadPoolExecutor(max_workers=20) as executor:
133+
future_to_url = {executor.submit(check_url_exists, url): url for url in urls_to_check}
134+
for future in as_completed(future_to_url):
135+
url = future_to_url[future]
136+
try:
137+
url_exists[url] = future.result()
138+
except:
139+
url_exists[url] = False
140+
141+
log("Checked {} URLs in {:.2f}s".format(len(urls_to_check), time.time() - check_start))
142+
143+
# Build art dictionaries using check results
144+
results = {}
145+
for appid, img_icon_path, art_checks in game_art_info:
146+
art_dict = {}
147+
148+
# Add art types that don't need checking
149+
for art_type, config in ART_TYPE_CONFIG.items():
150+
if not config.get('fallback'):
151+
art_dict[art_type] = get_art_url(config['primary'], appid, img_icon_path)
152+
153+
# Add art types that were checked
154+
for art_type, primary_url, fallback_key in art_checks:
155+
if url_exists.get(primary_url, False):
156+
art_dict[art_type] = primary_url
157+
else:
158+
art_dict[art_type] = get_art_url(fallback_key, appid, img_icon_path)
159+
160+
results[appid] = art_dict
161+
162+
log("Resolved art for {} games in {:.2f}s".format(len(games), time.time() - start_time))
163+
return results
164+
165+
166+
def resolve_art_url(art_type, appid, img_icon_path=''):
98167
"""
99-
Deletes the cache containing the data about which art types are available or not
168+
Resolve a single art URL (legacy interface).
169+
For better performance, use resolve_art_for_all_games() instead.
100170
"""
101-
# If Kodi's request-cache module is updated to >0.7.3 , we will only need to issue cached_requests.cache.clear() which will handle all scenarios. Until then, we must recreate the backend ourselves
171+
config = ART_TYPE_CONFIG.get(art_type)
172+
if not config:
173+
return None
174+
175+
primary_url = get_art_url(config['primary'], appid, img_icon_path)
176+
fallback_key = config.get('fallback')
177+
178+
if fallback_key and not check_url_exists(primary_url):
179+
return get_art_url(fallback_key, appid, img_icon_path)
180+
181+
return primary_url
182+
183+
184+
def delete_cache():
185+
"""Delete the art availability cache."""
102186
try:
103-
cached_requests.cache.clear()
104-
except Exception:
105-
log('Failed to clear cache. Attempting manual deletion')
187+
cached_session.cache.clear()
188+
log("Art cache cleared successfully")
189+
except Exception as e:
190+
log("Failed to clear art cache: {}".format(e))
106191
try:
107-
os.remove(ART_AVAILABILITY_CACHE_FILE + ".sqlite")
192+
os.remove(ART_CACHE_FILE + ".sqlite")
193+
log("Art cache file deleted")
108194
except:
109-
log('Failed to delete cache file')
110-
cached_requests.cache.responses = requests_cache.backends.storage.dbdict.DbPickleDict(ART_AVAILABILITY_CACHE_FILE + ".sqlite", 'responses', fast_save=True)
111-
cached_requests.cache.keys_map = requests_cache.backends.storage.dbdict.DbDict(ART_AVAILABILITY_CACHE_FILE + ".sqlite", 'urls')
195+
log("Failed to delete art cache file")
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Kodi Media Center language file
2+
# Addon Name: Steam Library
3+
# Addon id: plugin.program.steam.library
4+
msgid ""
5+
msgstr ""
6+
"Content-Type: text/plain; charset=UTF-8\n"
7+
"Content-Transfer-Encoding: 8bit\n"
8+
"Language: en\n"
9+
10+
# Categories
11+
msgctxt "#30100"
12+
msgid "Required Steam Details"
13+
msgstr ""
14+
15+
msgctxt "#30101"
16+
msgid "Cache Management"
17+
msgstr ""
18+
19+
msgctxt "#30102"
20+
msgid "Debugging"
21+
msgstr ""
22+
23+
# Required Steam Details settings
24+
msgctxt "#30000"
25+
msgid "Your Steam user id"
26+
msgstr ""
27+
28+
msgctxt "#30001"
29+
msgid "Your Steam web API key"
30+
msgstr ""
31+
32+
msgctxt "#30002"
33+
msgid "Your Steam executable file (Steam.exe)"
34+
msgstr ""
35+
36+
msgctxt "#30003"
37+
msgid "Your Steam folder"
38+
msgstr ""
39+
40+
msgctxt "#30004"
41+
msgid "Arguments to pass to your Steam executable"
42+
msgstr ""
43+
44+
# Cache Management settings
45+
msgctxt "#30010"
46+
msgid "Minutes before games list cache expires"
47+
msgstr ""
48+
49+
msgctxt "#30011"
50+
msgid "Months before arts cache expires"
51+
msgstr ""
52+
53+
msgctxt "#30012"
54+
msgid "Clear games and arts cache"
55+
msgstr ""
56+
57+
# Debugging settings
58+
msgctxt "#30020"
59+
msgid "Enable debugging mode"
60+
msgstr ""
61+
62+
msgctxt "#30021"
63+
msgid "Enable art fallback (first launch may be slower)"
64+
msgstr ""

resources/lib/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Bundled third-party libraries

0 commit comments

Comments
 (0)