Skip to content

Commit 5990592

Browse files
committed
add background service for fetching game metadata from Steam Store API
1 parent 7d021a1 commit 5990592

4 files changed

Lines changed: 453 additions & 1 deletion

File tree

addon.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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>

resources/main.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import xbmcplugin
66

77
from . import arts
8+
from . import metadata
89
from . import registry
910
from . import steam
1011
from .util import *
@@ -134,6 +135,7 @@ def run(appid):
134135
def delete_cache():
135136
steam.delete_cache()
136137
arts.delete_cache()
138+
metadata.delete_cache()
137139

138140

139141
def create_directory_items(app_entries):
@@ -154,15 +156,44 @@ def create_directory_items(app_entries):
154156
# Resolve all artwork URLs in parallel (with fallback checking)
155157
all_art = arts.resolve_art_for_all_games(app_entries)
156158

159+
# Fetch metadata for all games (cached or from API)
160+
appids = [str(app['appid']) for app in app_entries]
161+
all_metadata = metadata.get_metadata_for_games(appids)
162+
157163
directory_items = []
158164
for app_entry in app_entries:
159165
appid = str(app_entry['appid'])
160166
name = app_entry['name']
167+
game_metadata = all_metadata.get(appid, {})
161168

162169
run_url = plugin.url_for(run, appid=appid)
163170
item = xbmcgui.ListItem(name)
164171
item.setUniqueIDs({'steam': appid, 'steam_img_icon': app_entry['img_icon_url']})
165-
item.setInfo('video', {'playcount': app_entry.get('playtime_forever', 0)})
172+
173+
# Build info labels with metadata
174+
info_labels = {
175+
'title': name,
176+
'playcount': app_entry.get('playtime_forever', 0),
177+
}
178+
179+
if game_metadata:
180+
if game_metadata.get('short_description'):
181+
info_labels['plot'] = game_metadata['short_description']
182+
if game_metadata.get('genres'):
183+
info_labels['genre'] = ', '.join(game_metadata['genres'])
184+
if game_metadata.get('developers'):
185+
info_labels['studio'] = game_metadata['developers'][0]
186+
if game_metadata.get('release_date'):
187+
# Try to extract year from release date
188+
try:
189+
year = int(game_metadata['release_date'].split()[-1])
190+
info_labels['year'] = year
191+
except:
192+
pass
193+
if game_metadata.get('metacritic'):
194+
info_labels['rating'] = game_metadata['metacritic'] / 10.0
195+
196+
item.setInfo('video', info_labels)
166197
item.setContentLookup(False) # Tells Kodi not to send HEAD requests (used to determine MIME type for example) to the item's run URL.
167198

168199
item.addContextMenuItems([('Play', 'RunPlugin(' + run_url + ')'),

resources/metadata.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

Comments
 (0)