Skip to content

Commit 7d021a1

Browse files
committed
improve artwork resolution with parallel requests and fallbacks
- Use ThreadPoolExecutor to check artwork URLs in parallel (20 workers) - Fall back to header.jpg when poster/hero images unavailable - Cache HEAD request results for 2 months - Reduces load time from ~25s to ~2s (first load) or ~0.1s (cached)
1 parent 37dd934 commit 7d021a1

2 files changed

Lines changed: 180 additions & 99 deletions

File tree

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")

resources/main.py

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import routing
33
import sys
4+
import time
45
import xbmcplugin
56

67
from . import arts
@@ -25,25 +26,34 @@ def index():
2526

2627
@plugin.route('/all')
2728
def all_games():
29+
start_time = time.time()
30+
2831
if not all_required_credentials_available():
2932
return
3033

3134
try:
35+
api_start = time.time()
3236
steam_games_details = steam.get_user_games(__addon__.getSetting('steam-key'), __addon__.getSetting('steam-id'))
37+
log("Steam API call took {:.2f}s".format(time.time() - api_start))
3338

3439
except IOError as e:
3540
# something went wrong, can't scan the steam library
3641
show_error(e, 'An unexpected error has occurred while contacting Steam. Please ensure your Steam credentials are correct and then try again. '
3742
'If this problem persists please contact support.')
3843
return
3944

45+
items_start = time.time()
4046
directory_items = create_directory_items(steam_games_details)
47+
log("Creating directory items took {:.2f}s for {} games".format(time.time() - items_start, len(directory_items)))
48+
4149
xbmcplugin.addDirectoryItems(plugin.handle, directory_items)
4250

4351
xbmcplugin.addSortMethod(plugin.handle, xbmcplugin.SORT_METHOD_LABEL)
4452
xbmcplugin.addSortMethod(plugin.handle, xbmcplugin.SORT_METHOD_PLAYCOUNT)
4553
xbmcplugin.endOfDirectory(plugin.handle, succeeded=True)
4654

55+
log("Total /all route took {:.2f}s".format(time.time() - start_time))
56+
4757

4858
@plugin.route('/installed')
4959
def installed_games():
@@ -138,6 +148,12 @@ def create_directory_items(app_entries):
138148
xbmcplugin.setContent(plugin.handle, "movies")
139149
# TODO setContent to games when more skins support this content type.
140150

151+
# Convert to list to allow multiple iterations and get count
152+
app_entries = list(app_entries)
153+
154+
# Resolve all artwork URLs in parallel (with fallback checking)
155+
all_art = arts.resolve_art_for_all_games(app_entries)
156+
141157
directory_items = []
142158
for app_entry in app_entries:
143159
appid = str(app_entry['appid'])
@@ -153,33 +169,14 @@ def create_directory_items(app_entries):
153169
('Install', 'RunPlugin(' + plugin.url_for(install, appid=appid) + ')')],
154170
replaceItems=True) # Since we set the content type to "movies", default movie context elements may appear. We replace them.
155171

156-
art_dictionary = create_arts_dictionary(app_entry)
157-
item.setArt(art_dictionary)
172+
# Use pre-resolved artwork
173+
item.setArt(all_art.get(appid, {}))
158174

159175
directory_items.append((run_url, item, False))
160176

161177
return directory_items
162178

163179

164-
def create_arts_dictionary(app_entry):
165-
"""
166-
Creates a dictionary of arts keys and their associated links, for a given app entry.
167-
:param app_entry: dictionary of app information, containing at least the keys : appid, img_icon_url, img_logo_url
168-
:return: dictionary of arts for the app.
169-
"""
170-
171-
appid = str(app_entry['appid'])
172-
img_icon_url = app_entry['img_icon_url']
173-
art_dictionary = {}
174-
175-
# Multiple fanart https://kodi.wiki/view/Artwork_types#fanart.23
176-
SUPPORTED_ART_TYPES = ['poster', 'landscape', 'banner', 'clearlogo', 'thumb', 'fanart', 'fanart1', 'fanart2', 'icon']
177-
178-
for art_type in SUPPORTED_ART_TYPES:
179-
art_dictionary[art_type] = arts.resolve_art_url(art_type, appid, img_icon_url)
180-
return art_dictionary
181-
182-
183180
def main():
184181
log('steam-id = ' + ('*' * 17 if __addon__.getSetting('steam-id') else '(not set)'))
185182
log('steam-key = ' + ('*' * 32 if __addon__.getSetting('steam-key') else '(not set)'))

0 commit comments

Comments
 (0)