|
| 1 | +""" |
| 2 | +Fetch and cache game metadata from Steam Store API. |
| 3 | +
|
| 4 | +Rate limited to respect Steam's API limits (~200 requests per 5 minutes). |
| 5 | +Metadata is fetched by a background service and cached for 30 days. |
| 6 | +""" |
| 7 | +import json |
| 8 | +import os |
| 9 | +import sqlite3 |
| 10 | +import time |
| 11 | + |
| 12 | +import requests |
| 13 | +import xbmcaddon |
| 14 | +import xbmcvfs |
| 15 | + |
| 16 | +from .util import log |
| 17 | + |
| 18 | +__addon__ = xbmcaddon.Addon() |
| 19 | + |
| 20 | +# Cache setup |
| 21 | +addonUserDataFolder = xbmcvfs.translatePath(__addon__.getAddonInfo('profile')) |
| 22 | +METADATA_CACHE_FILE = xbmcvfs.translatePath(os.path.join(addonUserDataFolder, 'metadata_cache.sqlite')) |
| 23 | + |
| 24 | +# Cache duration: 30 days in seconds |
| 25 | +CACHE_DURATION = 30 * 24 * 60 * 60 |
| 26 | + |
| 27 | +# Steam Store API |
| 28 | +STEAM_STORE_API = 'https://store.steampowered.com/api/appdetails' |
| 29 | + |
| 30 | +# Rate limiting: Steam allows ~200 requests per 5 minutes |
| 31 | +# We'll be conservative: 1 request per 2 seconds |
| 32 | +RATE_LIMIT_DELAY = 2.0 # seconds between requests |
| 33 | + |
| 34 | + |
| 35 | +def _get_last_request_time(): |
| 36 | + """Get the last API request timestamp from the database.""" |
| 37 | + try: |
| 38 | + conn = sqlite3.connect(METADATA_CACHE_FILE) |
| 39 | + cursor = conn.execute( |
| 40 | + "SELECT value FROM settings WHERE key = 'last_request_time'" |
| 41 | + ) |
| 42 | + row = cursor.fetchone() |
| 43 | + conn.close() |
| 44 | + if row: |
| 45 | + return float(row[0]) |
| 46 | + except: |
| 47 | + pass |
| 48 | + return 0 |
| 49 | + |
| 50 | + |
| 51 | +def _set_last_request_time(timestamp): |
| 52 | + """Store the last API request timestamp in the database.""" |
| 53 | + try: |
| 54 | + conn = sqlite3.connect(METADATA_CACHE_FILE) |
| 55 | + conn.execute( |
| 56 | + "INSERT OR REPLACE INTO settings (key, value) VALUES ('last_request_time', ?)", |
| 57 | + (str(timestamp),) |
| 58 | + ) |
| 59 | + conn.commit() |
| 60 | + conn.close() |
| 61 | + except Exception as e: |
| 62 | + log("Failed to store last_request_time: {}".format(e)) |
| 63 | + |
| 64 | + |
| 65 | +def get_time_until_next_request(): |
| 66 | + """Get seconds until we can make another API request. Returns 0 if ready.""" |
| 67 | + last_time = _get_last_request_time() |
| 68 | + elapsed = time.time() - last_time |
| 69 | + if elapsed >= RATE_LIMIT_DELAY: |
| 70 | + return 0 |
| 71 | + return RATE_LIMIT_DELAY - elapsed |
| 72 | + |
| 73 | + |
| 74 | +def can_make_request(): |
| 75 | + """Check if we can make an API request without violating rate limits.""" |
| 76 | + return get_time_until_next_request() <= 0 |
| 77 | + |
| 78 | + |
| 79 | +def init_cache(): |
| 80 | + """Initialize the metadata cache database.""" |
| 81 | + os.makedirs(os.path.dirname(METADATA_CACHE_FILE), exist_ok=True) |
| 82 | + conn = sqlite3.connect(METADATA_CACHE_FILE) |
| 83 | + conn.execute(''' |
| 84 | + CREATE TABLE IF NOT EXISTS metadata ( |
| 85 | + appid TEXT PRIMARY KEY, |
| 86 | + data TEXT, |
| 87 | + fetched_at INTEGER |
| 88 | + ) |
| 89 | + ''') |
| 90 | + conn.execute(''' |
| 91 | + CREATE TABLE IF NOT EXISTS settings ( |
| 92 | + key TEXT PRIMARY KEY, |
| 93 | + value TEXT |
| 94 | + ) |
| 95 | + ''') |
| 96 | + conn.commit() |
| 97 | + conn.close() |
| 98 | + |
| 99 | + |
| 100 | +def get_cached_metadata(appid): |
| 101 | + """Get cached metadata for an appid, or None if not cached/expired.""" |
| 102 | + try: |
| 103 | + conn = sqlite3.connect(METADATA_CACHE_FILE) |
| 104 | + cursor = conn.execute( |
| 105 | + 'SELECT data, fetched_at FROM metadata WHERE appid = ?', |
| 106 | + (str(appid),) |
| 107 | + ) |
| 108 | + row = cursor.fetchone() |
| 109 | + conn.close() |
| 110 | + |
| 111 | + if row: |
| 112 | + data, fetched_at = row |
| 113 | + # Check if cache is still valid |
| 114 | + if time.time() - fetched_at < CACHE_DURATION: |
| 115 | + return json.loads(data) |
| 116 | + except: |
| 117 | + pass |
| 118 | + return None |
| 119 | + |
| 120 | + |
| 121 | +def cache_metadata(appid, data): |
| 122 | + """Store metadata in cache.""" |
| 123 | + try: |
| 124 | + conn = sqlite3.connect(METADATA_CACHE_FILE) |
| 125 | + conn.execute( |
| 126 | + 'INSERT OR REPLACE INTO metadata (appid, data, fetched_at) VALUES (?, ?, ?)', |
| 127 | + (str(appid), json.dumps(data), int(time.time())) |
| 128 | + ) |
| 129 | + conn.commit() |
| 130 | + conn.close() |
| 131 | + except Exception as e: |
| 132 | + log("Failed to cache metadata for {}: {}".format(appid, e)) |
| 133 | + |
| 134 | + |
| 135 | +def fetch_metadata_from_api(appid): |
| 136 | + """Fetch metadata for a single game from Steam Store API.""" |
| 137 | + # Record the request time BEFORE making the request |
| 138 | + _set_last_request_time(time.time()) |
| 139 | + |
| 140 | + try: |
| 141 | + response = requests.get( |
| 142 | + STEAM_STORE_API, |
| 143 | + params={'appids': appid, 'l': 'english'}, |
| 144 | + timeout=10 |
| 145 | + ) |
| 146 | + response.raise_for_status() |
| 147 | + |
| 148 | + result = response.json() |
| 149 | + app_data = result.get(str(appid), {}) |
| 150 | + |
| 151 | + if app_data.get('success') and 'data' in app_data: |
| 152 | + data = app_data['data'] |
| 153 | + # Extract the fields we care about |
| 154 | + metadata = { |
| 155 | + 'short_description': data.get('short_description', ''), |
| 156 | + 'developers': data.get('developers', []), |
| 157 | + 'publishers': data.get('publishers', []), |
| 158 | + 'genres': [g['description'] for g in data.get('genres', [])], |
| 159 | + 'release_date': data.get('release_date', {}).get('date', ''), |
| 160 | + 'metacritic': data.get('metacritic', {}).get('score'), |
| 161 | + 'categories': [c['description'] for c in data.get('categories', [])], |
| 162 | + } |
| 163 | + return metadata |
| 164 | + except Exception as e: |
| 165 | + log("Failed to fetch metadata for {}: {}".format(appid, e)) |
| 166 | + |
| 167 | + return None |
| 168 | + |
| 169 | + |
| 170 | +def get_metadata(appid): |
| 171 | + """ |
| 172 | + Get cached metadata for a game. |
| 173 | + Returns dict with metadata or empty dict if not cached. |
| 174 | + Does NOT fetch from API - that's handled by the background service. |
| 175 | + """ |
| 176 | + cached = get_cached_metadata(appid) |
| 177 | + return cached if cached is not None else {} |
| 178 | + |
| 179 | + |
| 180 | +def get_metadata_for_games(appids): |
| 181 | + """ |
| 182 | + Get cached metadata for multiple games. |
| 183 | + Returns dict mapping appid -> metadata (empty dict if not cached). |
| 184 | + Does NOT fetch from API - that's handled by the background service. |
| 185 | +
|
| 186 | + :param appids: list of appids to fetch |
| 187 | + :return: dict mapping appid -> metadata |
| 188 | + """ |
| 189 | + init_cache() |
| 190 | + results = {} |
| 191 | + cached_count = 0 |
| 192 | + |
| 193 | + for appid in appids: |
| 194 | + appid = str(appid) |
| 195 | + cached = get_cached_metadata(appid) |
| 196 | + if cached is not None: |
| 197 | + results[appid] = cached |
| 198 | + cached_count += 1 |
| 199 | + else: |
| 200 | + results[appid] = {} |
| 201 | + |
| 202 | + log("Metadata: {} cached, {} pending".format(cached_count, len(appids) - cached_count)) |
| 203 | + return results |
| 204 | + |
| 205 | + |
| 206 | +def get_uncached_appids(appids): |
| 207 | + """ |
| 208 | + Get list of appids that don't have cached metadata. |
| 209 | + Used by the background service to know what to fetch. |
| 210 | +
|
| 211 | + :param appids: list of appids to check |
| 212 | + :return: list of appids needing metadata |
| 213 | + """ |
| 214 | + init_cache() |
| 215 | + uncached = [] |
| 216 | + |
| 217 | + for appid in appids: |
| 218 | + appid = str(appid) |
| 219 | + if get_cached_metadata(appid) is None: |
| 220 | + uncached.append(appid) |
| 221 | + |
| 222 | + return uncached |
| 223 | + |
| 224 | + |
| 225 | +def fetch_and_cache_metadata(appid): |
| 226 | + """ |
| 227 | + Fetch metadata for a single game and cache it. |
| 228 | + Called by the background service. |
| 229 | + Returns True if successful, False otherwise. |
| 230 | + """ |
| 231 | + if not can_make_request(): |
| 232 | + return False |
| 233 | + |
| 234 | + metadata = fetch_metadata_from_api(appid) |
| 235 | + |
| 236 | + if metadata: |
| 237 | + cache_metadata(appid, metadata) |
| 238 | + return True |
| 239 | + |
| 240 | + # Cache empty result to avoid re-fetching failed games |
| 241 | + cache_metadata(appid, {}) |
| 242 | + return True |
| 243 | + |
| 244 | + |
| 245 | +def delete_cache(): |
| 246 | + """Delete the metadata cache.""" |
| 247 | + try: |
| 248 | + if os.path.exists(METADATA_CACHE_FILE): |
| 249 | + os.remove(METADATA_CACHE_FILE) |
| 250 | + log("Metadata cache deleted") |
| 251 | + except Exception as e: |
| 252 | + log("Failed to delete metadata cache: {}".format(e)) |
0 commit comments