feat: optimize get_egs_game_description_async to minimize API requests and handle DNS failures
All checks were successful
Code and build check / Check code (push) Successful in 1m20s
Code and build check / Build with uv (push) Successful in 46s

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
Boris Yumankulov 2025-06-03 20:48:41 +05:00
parent ec3db0e1f2
commit 768d437dda
Signed by: Boria138
GPG Key ID: 14B4A5673FD39C76

View File

@ -1,4 +1,6 @@
import re
import requests import requests
import requests.exceptions
import threading import threading
import orjson import orjson
from pathlib import Path from pathlib import Path
@ -24,27 +26,23 @@ def get_cache_dir() -> Path:
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir return cache_dir
def get_egs_game_description_async( def get_egs_game_description_async(
app_name: str, app_name: str,
callback: Callable[[str], None], callback: Callable[[str], None],
namespace: str | None = None,
cache_ttl: int = 3600 cache_ttl: int = 3600
) -> None: ) -> None:
""" """
Asynchronously fetches the game description from the Epic Games Store API. Asynchronously fetches the game description from the Epic Games Store API.
Prioritizes the legacy store-content API using a derived slug. Prioritizes GraphQL API with namespace for slug and description.
Falls back to GraphQL API if legacy API returns empty or 404, retrying legacy API with GraphQL productSlug if needed. Falls back to legacy API if GraphQL provides a slug but no description.
Retries GraphQL with English locale if system language yields no description. Caches results in ~/.cache/PortProtonQT/egs_app_{app_name}.json.
Uses per-app cache files named egs_app_{app_name}.json in ~/.cache/PortProtonQT. Handles DNS resolution failures gracefully.
Checks the cache first; if the description is cached and not expired, returns it.
Prioritizes the page with type 'productHome' for the base game description in legacy API.
""" """
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = cache_dir / f"egs_app_{app_name.lower().replace(':', '_').replace(' ', '_')}.json" cache_file = cache_dir / f"egs_app_{app_name.lower().replace(':', '_').replace(' ', '_')}.json"
# Initialize content to avoid unbound variable # Check cache
content = b""
# Load existing cache
if cache_file.exists(): if cache_file.exists():
try: try:
with open(cache_file, "rb") as f: with open(cache_file, "rb") as f:
@ -74,10 +72,6 @@ def get_egs_game_description_async(
app_name, app_name,
str(e) str(e)
) )
logger.debug(
"Cache file content (first 100 chars): %s",
content[:100].decode('utf-8', errors='replace')
)
cache_file.unlink(missing_ok=True) cache_file.unlink(missing_ok=True)
except Exception as e: except Exception as e:
logger.error( logger.error(
@ -92,17 +86,56 @@ def get_egs_game_description_async(
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) EpicGamesLauncher" "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) EpicGamesLauncher"
} }
search_url = "https://graphql.epicgames.com/graphql"
def fetch_description(): def slug_from_title(title: str) -> str:
description = "" """Derives a slug from the game title, preserving numbers and handling special characters."""
slug = app_name.lower().replace(":", "").replace(" ", "-") # Keep letters, numbers, and spaces; replace spaces with hyphens
legacy_url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{slug}" cleaned = re.sub(r'[^a-z0-9 ]', '', title.lower()).strip()
return re.sub(r'\s+', '-', cleaned)
# Helper function to fetch description via legacy API def get_product_slug(namespace: str) -> str:
def fetch_legacy_description(url: str) -> str: """Fetches the product slug using the namespace via GraphQL."""
search_query = {
"query": """
query {
Catalog {
catalogNs(namespace: $namespace) {
mappings(pageType: "productHome") {
pageSlug
pageType
}
}
}
}
""",
"variables": {"namespace": namespace}
}
try: try:
response = requests.get(url, timeout=5) response = requests.post(
"https://launcher.store.epicgames.com/graphql",
json=search_query,
headers=headers,
timeout=5
)
response.raise_for_status()
data = orjson.loads(response.content)
mappings = data.get("data", {}).get("Catalog", {}).get("catalogNs", {}).get("mappings", [])
for mapping in mappings:
if mapping.get("pageType") == "productHome":
return mapping.get("pageSlug", "")
logger.warning("No productHome slug found for namespace %s", namespace)
return ""
except requests.RequestException as e:
logger.warning("Failed to fetch product slug for namespace %s: %s", namespace, str(e))
return ""
except orjson.JSONDecodeError:
logger.warning("Invalid JSON response for namespace %s", namespace)
return ""
def fetch_legacy_description(url: str) -> str:
"""Fetches description from the legacy API, handling DNS failures."""
try:
response = requests.get(url, headers=headers, timeout=5)
response.raise_for_status() response.raise_for_status()
data = orjson.loads(response.content) data = orjson.loads(response.content)
if not isinstance(data, dict): if not isinstance(data, dict):
@ -113,7 +146,6 @@ def get_egs_game_description_async(
for page in pages: for page in pages:
if page.get("type") == "productHome": if page.get("type") == "productHome":
return page.get("data", {}).get("about", {}).get("shortDescription", "") return page.get("data", {}).get("about", {}).get("shortDescription", "")
else:
return pages[0].get("data", {}).get("about", {}).get("shortDescription", "") return pages[0].get("data", {}).get("about", {}).get("shortDescription", "")
return "" return ""
except requests.HTTPError as e: except requests.HTTPError as e:
@ -122,6 +154,9 @@ def get_egs_game_description_async(
else: else:
logger.warning("HTTP error in legacy API for %s: %s", app_name, str(e)) logger.warning("HTTP error in legacy API for %s: %s", app_name, str(e))
return "" return ""
except requests.exceptions.ConnectionError as e:
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
return ""
except requests.RequestException as e: except requests.RequestException as e:
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e)) logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
return "" return ""
@ -129,25 +164,68 @@ def get_egs_game_description_async(
logger.warning("Invalid JSON response for %s in legacy API", app_name) logger.warning("Invalid JSON response for %s in legacy API", app_name)
return "" return ""
# Helper function to fetch description and productSlug via GraphQL def fetch_graphql_description(namespace: str | None, locale: str) -> tuple[str, str]:
def fetch_graphql_description(locale: str) -> tuple[str, str]: """Fetches description and slug from GraphQL API using namespace or title."""
if namespace:
search_query = { search_query = {
"query": "query search($keywords: String!, $locale: String) { Catalog { searchStore(keywords: $keywords, locale: $locale) { elements { title namespace productSlug description } } } }", "query": """
"variables": { query {
"keywords": app_name, Product {
"locale": locale sandbox(sandboxId: $namespace) {
configuration {
... on StoreConfiguration {
configs {
shortDescription
} }
} }
}
}
catalogNs {
mappings(pageType: "productHome") {
pageSlug
pageType
}
}
}
}
""",
"variables": {"namespace": namespace}
}
url = "https://launcher.store.epicgames.com/graphql"
else:
search_query = {
"query": """
query search($keywords: String!, $locale: String) {
Catalog {
searchStore(keywords: $keywords, locale: $locale) {
elements { title namespace productSlug description }
}
}
}
""",
"variables": {"keywords": app_name, "locale": locale}
}
url = "https://graphql.epicgames.com/graphql"
try: try:
response = requests.post(search_url, json=search_query, headers=headers, timeout=5) response = requests.post(url, json=search_query, headers=headers, timeout=5)
response.raise_for_status() response.raise_for_status()
data = orjson.loads(response.content) data = orjson.loads(response.content)
if isinstance(data, dict) and "data" in data: if namespace:
configs = data.get("data", {}).get("Product", {}).get("sandbox", {}).get("configuration", [{}])[0].get("configs", {})
description = configs.get("shortDescription", "")
mappings = data.get("data", {}).get("Product", {}).get("catalogNs", {}).get("mappings", [])
slug = next((m.get("pageSlug", "") for m in mappings if m.get("pageType") == "productHome"), "")
return description, slug
else:
elements = data.get("data", {}).get("Catalog", {}).get("searchStore", {}).get("elements", []) elements = data.get("data", {}).get("Catalog", {}).get("searchStore", {}).get("elements", [])
for element in elements: for element in elements:
if isinstance(element, dict) and element.get("title", "").lower() == app_name.lower() and element.get("productSlug") and not any(substring in element.get("title", "").lower() for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"]): if (isinstance(element, dict) and
element.get("title", "").lower() == app_name.lower() and
element.get("productSlug") and
not any(substring in element.get("title", "").lower()
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
return element.get("description", ""), element.get("productSlug", "") return element.get("description", ""), element.get("productSlug", "")
logger.warning("No valid description or productSlug found for %s in GraphQL with locale %s", app_name, locale)
return "", "" return "", ""
except requests.RequestException as e: except requests.RequestException as e:
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e)) logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
@ -156,35 +234,37 @@ def get_egs_game_description_async(
logger.warning("Invalid JSON response for %s with locale %s", app_name, locale) logger.warning("Invalid JSON response for %s with locale %s", app_name, locale)
return "", "" return "", ""
try: def fetch_description():
# Step 1: Try legacy API with derived slug description = ""
description = fetch_legacy_description(legacy_url) product_slug = ""
product_slug = None
# Step 2: If legacy API fails, try GraphQL and possibly retry legacy with GraphQL slug # Step 1: Try GraphQL with namespace to get description and slug
if namespace:
description, product_slug = fetch_graphql_description(namespace, lang)
if description:
logger.debug("Fetched description from GraphQL for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
# Step 2: If no description or no namespace, try legacy API with slug
if not description: if not description:
logger.info("No valid description from legacy API for %s, falling back to GraphQL", app_name) if not product_slug:
description, product_slug = fetch_graphql_description(lang) product_slug = slug_from_title(app_name)
# Retry legacy API with GraphQL productSlug if available
if not description and product_slug:
legacy_url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{product_slug}" legacy_url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{product_slug}"
try:
description = fetch_legacy_description(legacy_url) description = fetch_legacy_description(legacy_url)
if description: if description:
logger.debug("Fetched description from legacy API with GraphQL slug for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description) logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
except requests.exceptions.ConnectionError:
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
# Step 3: If still no description, retry GraphQL with English locale # Step 3: If still no description and no namespace, try GraphQL with title
if not description and not namespace:
description, _ = fetch_graphql_description(None, lang)
if description:
logger.debug("Fetched description from GraphQL title search for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
# Step 4: If no description found, log and return empty
if not description: if not description:
logger.info("No description in system language %s for %s, retrying GraphQL with en-US", lang, app_name) logger.warning("No valid description found for %s", app_name)
description, _ = fetch_graphql_description("en-US")
if not description:
logger.warning("No valid description found for %s after all queries", app_name)
logger.debug(
"Final description for %s: %s",
app_name,
(description[:100] + "...") if len(description) > 100 else description
)
# Save to cache # Save to cache
cache_entry = {"description": description, "timestamp": time.time()} cache_entry = {"description": description, "timestamp": time.time()}
@ -198,13 +278,9 @@ def get_egs_game_description_async(
logger.error("Failed to save description cache for %s: %s", app_name, str(e)) logger.error("Failed to save description cache for %s: %s", app_name, str(e))
callback(description) callback(description)
except Exception as e:
logger.error("Unexpected error fetching EGS description for %s: %s", app_name, str(e))
callback("")
thread = threading.Thread(target=fetch_description, daemon=True) thread = threading.Thread(target=fetch_description, daemon=True)
thread.start() thread.start()
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]): def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
""" """
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback. Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.