Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions web/routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ def settings_page(
from ..services.settings import (
get_setting, STEAM_ID, STEAM_API_KEY, IGDB_CLIENT_ID, IGDB_CLIENT_SECRET,
ITCH_API_KEY, HUMBLE_SESSION_COOKIE, BATTLENET_SESSION_COOKIE, GOG_DB_PATH,
EA_BEARER_TOKEN, IGDB_MATCH_THRESHOLD, LOCAL_GAMES_PATHS
EA_BEARER_TOKEN, IGDB_MATCH_THRESHOLD, LOCAL_GAMES_PATHS, XBOX_XSTS_TOKEN,
XBOX_GAMEPASS_MARKET, XBOX_GAMEPASS_PLAN
)
from ..sources.local import discover_local_game_paths

Expand Down Expand Up @@ -63,6 +64,9 @@ def settings_page(
"gog_db_path": get_setting(GOG_DB_PATH, ""),
"ea_bearer_token": get_setting(EA_BEARER_TOKEN, ""),
"local_games_paths": local_games_paths_value,
"xbox_xsts_token": get_setting(XBOX_XSTS_TOKEN, ""),
"xbox_gamepass_market": get_setting(XBOX_GAMEPASS_MARKET, ""),
"xbox_gamepass_plan": get_setting(XBOX_GAMEPASS_PLAN, ""),
}
success_flag = success == "1"

Expand Down Expand Up @@ -97,13 +101,17 @@ def save_settings(
gog_db_path: str = Form(default=""),
ea_bearer_token: str = Form(default=""),
local_games_paths: str = Form(default=""),
xbox_xsts_token: str = Form(default=""),
xbox_gamepass_market: str = Form(default=""),
xbox_gamepass_plan: str = Form(default=""),
):
"""Save settings from the form."""
# Import here to avoid circular imports
from ..services.settings import (
set_setting, STEAM_ID, STEAM_API_KEY, IGDB_CLIENT_ID, IGDB_CLIENT_SECRET,
ITCH_API_KEY, HUMBLE_SESSION_COOKIE, BATTLENET_SESSION_COOKIE, GOG_DB_PATH,
EA_BEARER_TOKEN, IGDB_MATCH_THRESHOLD, LOCAL_GAMES_PATHS
EA_BEARER_TOKEN, IGDB_MATCH_THRESHOLD, LOCAL_GAMES_PATHS, XBOX_XSTS_TOKEN,
XBOX_GAMEPASS_MARKET, XBOX_GAMEPASS_PLAN
)

# Detect if running in Docker
Expand All @@ -120,6 +128,9 @@ def save_settings(
set_setting(BATTLENET_SESSION_COOKIE, battlenet_session_cookie.strip())
set_setting(GOG_DB_PATH, gog_db_path.strip())
set_setting(EA_BEARER_TOKEN, ea_bearer_token.strip())
set_setting(XBOX_XSTS_TOKEN, xbox_xsts_token.strip())
set_setting(XBOX_GAMEPASS_MARKET, xbox_gamepass_market.strip())
set_setting(XBOX_GAMEPASS_PLAN, xbox_gamepass_plan.strip())

# Only save LOCAL_GAMES_PATHS if not in Docker mode
if not is_docker:
Expand Down
2 changes: 1 addition & 1 deletion web/services/database_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ def import_xbox_games(conn):
if not games:
print(" No Xbox games found or not configured")
print(" Add your XSTS token in Settings to import owned games")
print(" Game Pass catalog will be imported regardless")
print(" Select Game Pass plan in Settings to import catalog")
return 0

count = 0
Expand Down
10 changes: 10 additions & 0 deletions web/services/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
GOG_DB_PATH = "gog_db_path"
EA_BEARER_TOKEN = "ea_bearer_token"
XBOX_XSTS_TOKEN = "xbox_xsts_token"
XBOX_GAMEPASS_MARKET = "xbox_gamepass_market"
XBOX_GAMEPASS_PLAN = "xbox_gamepass_plan"
LOCAL_GAMES_PATHS = "local_games_paths"
IGDB_MATCH_THRESHOLD = "igdb_match_threshold"

Expand All @@ -34,6 +36,8 @@
GOG_DB_PATH: "GOG_DB_PATH",
EA_BEARER_TOKEN: "EA_BEARER_TOKEN",
XBOX_XSTS_TOKEN: "XBOX_XSTS_TOKEN",
XBOX_GAMEPASS_MARKET: "XBOX_GAMEPASS_MARKET",
XBOX_GAMEPASS_PLAN: "XBOX_GAMEPASS_PLAN",
LOCAL_GAMES_PATHS: "LOCAL_GAMES_PATHS",
IGDB_MATCH_THRESHOLD: "IGDB_MATCH_THRESHOLD",
}
Expand Down Expand Up @@ -179,6 +183,12 @@ def get_xbox_credentials():
"xsts_token": get_setting(XBOX_XSTS_TOKEN),
}

def get_xbox_gamepass_settings():
"""Get Xbox Game Pass settings."""
return {
"market": get_setting(XBOX_GAMEPASS_MARKET),
"plan": get_setting(XBOX_GAMEPASS_PLAN),
}

def get_local_games_settings():
"""Get local games folder settings."""
Expand Down
67 changes: 51 additions & 16 deletions web/sources/xbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,35 @@
import json
import requests

from ..services.settings import get_xbox_credentials
from ..services.settings import get_xbox_credentials, get_xbox_gamepass_settings

# Xbox API endpoints
# Reference: https://www.xbox.com/en-us/xbox-game-pass/games/js/xgpcatPopulate-2025.js

TITLEHUB_ENDPOINT = "https://titlehub.xboxlive.com"
COLLECTIONS_ENDPOINT = "https://collections.mp.microsoft.com/v9.0/collections/publisherQuery"
GAMEPASS_CATALOG_ENDPOINT = "https://catalog.gamepass.com/sigls/v2"
GAMEPASS_CATALOG_ENDPOINT = "https://catalog.gamepass.com/sigls/v3"
DISPLAY_CATALOG_ENDPOINT = "https://displaycatalog.mp.microsoft.com/v7.0/products"

# Game Pass catalog IDs
# fdd9e2a7-0fee-49f6-ad69-4354098401ff = PC Game Pass
# f6f1f99f-9b49-4ccd-b3bf-4d9767a77f5e = Console Game Pass
# 29a81209-df6f-41fd-a528-2ae6b91f719c = EA Play
GAMEPASS_PC_CATALOG_ID = "fdd9e2a7-0fee-49f6-ad69-4354098401ff"
GAMEPASS_PLAN_MAP = {
"ultimate":
{
"collection": "97c6c862-d28a-4907-a3d5-c401f2296a53",
"subscription": "cfq7ttc0khs0"
},
"premium": {
"collection": "09a72c0d-c466-426a-9580-b78955d8173a",
"subscription": "cfq7ttc0p85b"
},
"essential": {
"collection": "34031711-5a70-4196-bab7-45757dc2294e",
"subscription": "cfq7ttc0k5dj"
},
"pc": {
"collection": "609d944c-d395-4c0a-9ea4-e9f39b52c1ad",
"subscription": "cfq7ttc0kgq8"
}
}

# Required headers for API requests
REQUIRED_HEADERS = {
Expand All @@ -27,6 +43,14 @@
"x-xbl-contract-version": "2",
}

def get_resolved_xbox_gamepass_settings():
"""Get Xbox Game Pass settings."""
gamepass_settings = get_xbox_gamepass_settings()
return {
"plan": gamepass_settings.get("plan", "ultimate"),
"market": gamepass_settings.get("market", "US")
}


def get_xsts_token():
"""Get stored XSTS token from settings."""
Expand Down Expand Up @@ -252,12 +276,13 @@ def get_owned_games_from_collections(token):
}

# Query for owned products
planInfo = get_resolved_xbox_gamepass_settings()
payload = {
"productIds": [],
"productSkuIds": [],
"idType": "ProductId",
"beneficiaries": [],
"market": "US",
"market": planInfo["market"],
"languages": ["en-US"],
"maxPageSize": 1000,
}
Expand Down Expand Up @@ -307,16 +332,22 @@ def get_owned_games_from_collections(token):
print(f" Error fetching from Collections API: {e}")
return []


def get_gamepass_catalog():
def get_gamepass_catalog(plan, market):
"""Fetch Game Pass PC catalog (public API, no auth required)."""
try:
if plan == "none":
print(" Game Pass import disabled - skipping")
return []

all_games = []


planInfo = GAMEPASS_PLAN_MAP[plan]
collectionId = planInfo['collection']
subscriptionId = planInfo['subscription']
# Fetch Game Pass catalog
url = f"{GAMEPASS_CATALOG_ENDPOINT}?id={GAMEPASS_PC_CATALOG_ID}&language=en-US&market=US"
url = f"{GAMEPASS_CATALOG_ENDPOINT}?id={collectionId}&language=en-US&market={market}&platformContext=pc&subscriptionContext={subscriptionId}"

print(" Fetching Game Pass catalog...")
print(f" Fetching Game Pass catalog for plan: {plan} (market: {market})...")
response = requests.get(url, headers=REQUIRED_HEADERS)

if response.status_code != 200:
Expand Down Expand Up @@ -359,7 +390,8 @@ def get_product_details(product_ids):
try:
# Build the products query
ids_param = ",".join(product_ids)
url = f"{DISPLAY_CATALOG_ENDPOINT}?bigIds={ids_param}&market=US&languages=en-US"
planInfo = get_resolved_xbox_gamepass_settings()
url = f"{DISPLAY_CATALOG_ENDPOINT}?bigIds={ids_param}&market={planInfo['market']}&languages=en-US"

response = requests.get(url, headers=REQUIRED_HEADERS)

Expand Down Expand Up @@ -430,6 +462,7 @@ def get_product_details(product_ids):
def get_xbox_library():
"""Fetch all games from Xbox - owned games + Game Pass catalog."""
token = get_xsts_token()
gamepass_settings = get_resolved_xbox_gamepass_settings()

print("Fetching Xbox library...")

Expand All @@ -451,7 +484,7 @@ def get_xbox_library():
print(" To import owned games, add your XSTS token in Settings")

# Then fetch Game Pass catalog (public API)
gamepass_games = get_gamepass_catalog()
gamepass_games = get_gamepass_catalog(gamepass_settings["plan"], gamepass_settings["market"])
print(f" Found {len(gamepass_games)} Game Pass games")

# Add Game Pass games that aren't already owned
Expand Down Expand Up @@ -488,14 +521,16 @@ def main():
parser.add_argument("--token", type=str, help="XSTS token (for testing)")
parser.add_argument("--gamepass-only", action="store_true", help="Only fetch Game Pass catalog")
parser.add_argument("--export", type=str, help="Export to JSON file instead of database")
parser.add_argument("--plan", type=str, choices=["ultimate", "premium", "essential", "pc"], default="ultimate", help="Game Pass plan to fetch (default: ultimate)")
parser.add_argument("--market", type=str, default="US", help="Game Pass market to fetch (default: US)")
args = parser.parse_args()

print("Xbox Library Import")
print("=" * 60)

if args.gamepass_only:
print("Fetching Game Pass catalog only...")
games = get_gamepass_catalog()
games = get_gamepass_catalog(args.plan, args.market)
elif args.token:
# Use provided token for testing
print("Using provided token...")
Expand Down
2 changes: 1 addition & 1 deletion web/templates/game_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -1544,7 +1544,7 @@ <h3>IGDB Data</h3>
<div id="igdb-status" class="igdb-status" style="display: none;"></div>
<p class="igdb-help">
Find the IGDB ID by searching on <a href="https://www.igdb.com/search?utf8=%E2%9C%93&type=1&q={{ game.name|urlencode }}" target="_blank" rel="noopener">igdb.com</a>.
The ID is in the URL (e.g., igdb.com/games/<strong>slug</strong>/123 → ID is 123).
The ID is displayed in a box to the right of the metadata pane (e.g. IGDB ID: 12345).
</p>
</div>
</div>
Expand Down
10 changes: 10 additions & 0 deletions web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,16 @@
cursor: pointer;
}

.sort-select option {
background: #1a1a2e;
color: #e4e4e4;
}

.sort-select option:checked {
background: #1a1a2e;
color: #e4e4e4;
}

.games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
Expand Down
33 changes: 33 additions & 0 deletions web/templates/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,27 @@
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
}

.form-group select {
width: 100%;
padding: 12px 16px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #e4e4e4;
font-size: 1rem;
}

.form-group select:focus {
outline: none;
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
}

.form-group select option {
background: #1a1a2e;
color: #e4e4e4;
}

.help-text {
font-size: 0.8rem;
color: #888;
Expand Down Expand Up @@ -1529,6 +1550,18 @@ <h4 style="color: #86328a; margin-bottom: 12px; font-size: 0.95rem;">Option 2: B
Game Pass catalog is public and will sync without a token. Token is only needed for your owned games.
</p>
</div>
<label for="xbox_gamepass_market">Xbox Game Pass Market</label>
<input type="text" id="xbox_gamepass_market" name="xbox_gamepass_market"
value="{{ settings.xbox_gamepass_market }}"
placeholder="e.g. US, AU">
<label for="xbox_gamepass_plan">Xbox Game Pass Plan</label>
<select id="xbox_gamepass_plan" name="xbox_gamepass_plan">
<option value="none" {% if not settings.xbox_gamepass_plan or settings.xbox_gamepass_plan == 'none' %}selected{% endif %}>None</option>
<option value="ultimate" {% if settings.xbox_gamepass_plan == 'ultimate' %}selected{% endif %}>Game Pass Ultimate</option>
<option value="premium" {% if settings.xbox_gamepass_plan == 'premium' %}selected{% endif %}>Game Pass Premium</option>
<option value="essential" {% if settings.xbox_gamepass_plan == 'essential' %}selected{% endif %}>Game Pass Essential</option>
<option value="pc" {% if settings.xbox_gamepass_plan == 'pc' %}selected{% endif %}>PC Game Pass</option>
</select>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion web/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def get_store_url(store, store_id, extra_data=None):
elif store == "xbox":
# Xbox Store URL
if store_id:
return f"https://www.xbox.com/games/store/{store_id}"
return f"https://www.xbox.com/games/store/game/{store_id}"
return None
return None

Expand Down