diff --git a/web/routes/settings.py b/web/routes/settings.py index 0637bae..d86b559 100644 --- a/web/routes/settings.py +++ b/web/routes/settings.py @@ -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 @@ -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" @@ -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 @@ -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: diff --git a/web/services/database_builder.py b/web/services/database_builder.py index d791fd5..12331d3 100644 --- a/web/services/database_builder.py +++ b/web/services/database_builder.py @@ -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 diff --git a/web/services/settings.py b/web/services/settings.py index 45ec555..45cd6d2 100644 --- a/web/services/settings.py +++ b/web/services/settings.py @@ -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" @@ -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", } @@ -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.""" diff --git a/web/sources/xbox.py b/web/sources/xbox.py index 12646ad..cfdfdc0 100644 --- a/web/sources/xbox.py +++ b/web/sources/xbox.py @@ -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 = { @@ -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", "none"), + "market": gamepass_settings.get("market", "US") + } + def get_xsts_token(): """Get stored XSTS token from settings.""" @@ -140,7 +164,7 @@ def get_xuid_from_token(token): return None -def get_owned_games(token, xuid=None): +def get_owned_games(token, market, xuid=None): """Fetch owned games from Xbox TitleHub API.""" try: auth_header, userhash = parse_xsts_token(token) @@ -155,7 +179,7 @@ def get_owned_games(token, xuid=None): if not xuid: print(" Could not determine XUID - trying alternative API") # Fall back to Collections API which doesn't need XUID - return get_owned_games_from_collections(token) + return get_owned_games_from_collections(token, market) headers = { **REQUIRED_HEADERS, @@ -180,7 +204,7 @@ def get_owned_games(token, xuid=None): if response.status_code != 200: print(f" TitleHub error: {response.status_code} - {response.text[:200]}") # Try Collections API as fallback - return get_owned_games_from_collections(token) + return get_owned_games_from_collections(token, market) try: data = response.json() @@ -238,7 +262,7 @@ def get_owned_games(token, xuid=None): return [] -def get_owned_games_from_collections(token): +def get_owned_games_from_collections(token, market): """Fetch owned games using Collections API (alternative method).""" try: auth_header, _ = parse_xsts_token(token) @@ -257,7 +281,7 @@ def get_owned_games_from_collections(token): "productSkuIds": [], "idType": "ProductId", "beneficiaries": [], - "market": "US", + "market": market, "languages": ["en-US"], "maxPageSize": 1000, } @@ -307,16 +331,25 @@ 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 = [] + + plan_info = GAMEPASS_PLAN_MAP.get(plan) + if not plan_info: + raise ValueError(f"Invalid Game Pass plan: {plan}") + collection_id = plan_info['collection'] + subscription_id = plan_info['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={collection_id}&language=en-US&market={market}&platformContext=pc&subscriptionContext={subscription_id}" - 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: @@ -339,7 +372,7 @@ def get_gamepass_catalog(): batch_size = 20 for i in range(0, len(product_ids), batch_size): batch = product_ids[i:i + batch_size] - details = get_product_details(batch) + details = get_product_details(batch, market) all_games.extend(details) return all_games @@ -351,7 +384,7 @@ def get_gamepass_catalog(): return [] -def get_product_details(product_ids): +def get_product_details(product_ids, market): """Fetch product details from Display Catalog API.""" if not product_ids: return [] @@ -359,7 +392,7 @@ 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" + url = f"{DISPLAY_CATALOG_ENDPOINT}?bigIds={ids_param}&market={market}&languages=en-US" response = requests.get(url, headers=REQUIRED_HEADERS) @@ -430,7 +463,9 @@ 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() + plan = gamepass_settings["plan"] + market = gamepass_settings["market"] print("Fetching Xbox library...") all_games = [] @@ -438,7 +473,7 @@ def get_xbox_library(): # First, try to get owned games if token is available if token: - owned_games = get_owned_games(token) + owned_games = get_owned_games(token, market) print(f" Found {len(owned_games)} owned Xbox games") for game in owned_games: @@ -451,7 +486,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(plan, market) print(f" Found {len(gamepass_games)} Game Pass games") # Add Game Pass games that aren't already owned @@ -488,6 +523,8 @@ 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") @@ -495,11 +532,11 @@ def main(): 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...") - games = get_owned_games(args.token) + games = get_owned_games(args.token, args.market) else: games = get_xbox_library() diff --git a/web/templates/game_detail.html b/web/templates/game_detail.html index 030e656..d109e70 100644 --- a/web/templates/game_detail.html +++ b/web/templates/game_detail.html @@ -1544,7 +1544,7 @@

IGDB Data

Find the IGDB ID by searching on igdb.com. - The ID is in the URL (e.g., igdb.com/games/slug/123 → ID is 123). + The ID is displayed in a box to the right of the metadata pane (e.g. IGDB ID: 12345).

diff --git a/web/templates/index.html b/web/templates/index.html index 61c0675..3ccf921 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -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)); diff --git a/web/templates/settings.html b/web/templates/settings.html index 66519d1..017aea2 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -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; @@ -1529,6 +1550,18 @@

Option 2: B Game Pass catalog is public and will sync without a token. Token is only needed for your owned games.

+ + + + diff --git a/web/utils/helpers.py b/web/utils/helpers.py index 75e84cd..aca9b2d 100644 --- a/web/utils/helpers.py +++ b/web/utils/helpers.py @@ -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