|
1 | | -import xbmcaddon |
2 | | -import xbmcvfs |
3 | | - |
4 | 1 | import os |
| 2 | +import time |
| 3 | +from concurrent.futures import ThreadPoolExecutor, as_completed |
5 | 4 | from datetime import timedelta |
| 5 | + |
6 | 6 | import requests |
7 | 7 | import requests_cache |
| 8 | +import xbmcaddon |
| 9 | +import xbmcvfs |
8 | 10 |
|
9 | 11 | from .util import log |
10 | 12 |
|
11 | 13 | __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 |
14 | 14 |
|
15 | | -# define the cache file to reside in the ..\Kodi\userdata\addon_data\(your addon) |
| 15 | +# Cache setup for HEAD requests |
16 | 16 | 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', |
30 | 35 | '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 |
32 | 36 | } |
33 | 37 |
|
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}, |
46 | 50 | } |
47 | 51 |
|
48 | 52 |
|
49 | | -def is_art_url_available(url, timeout=2): |
| 53 | +def check_url_exists(url, timeout=2): |
50 | 54 | """ |
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. |
56 | 56 | """ |
57 | | - result = False |
58 | 57 | 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) |
65 | 70 |
|
66 | 71 |
|
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=''): |
68 | 73 | """ |
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}. |
77 | 76 | """ |
78 | | - valid_art_url = None |
79 | | - requested_art = ARTS_ASSIGNMENTS.get(art_type, None) |
| 77 | + art_dict = {} |
| 78 | + urls_to_check = [] |
80 | 79 |
|
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') |
90 | 84 |
|
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 |
93 | 91 |
|
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) |
95 | 98 |
|
| 99 | + return art_dict |
96 | 100 |
|
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=''): |
98 | 167 | """ |
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. |
100 | 170 | """ |
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.""" |
102 | 186 | 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)) |
106 | 191 | try: |
107 | | - os.remove(ART_AVAILABILITY_CACHE_FILE + ".sqlite") |
| 192 | + os.remove(ART_CACHE_FILE + ".sqlite") |
| 193 | + log("Art cache file deleted") |
108 | 194 | 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") |
0 commit comments