mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 13:47:31 +01:00
The pinned howlongtobeatpy (1.0.23) takes a user agent in send_website_request_getcode and dropped parse_all_scripts; the call still matched 1.0.20, which only worked because the deployed env was behind its own pin. Now on the pinned API, and the env upgraded. Three modules carried the same executable/path constant block since the 250-line split; it is _store_tools. Four carried the same "restore the hours a refetch lost" loop; it is restore_prior_hours. Both detached launches go through one helper that returns the process. Thirteen try/except/else blocks are flat, dataclass fields come from dataclasses.fields, and the rest are one-line fixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
221 lines
7.0 KiB
Python
221 lines
7.0 KiB
Python
"""ProtonDB integration for Linux compatibility ratings.
|
|
|
|
Fetches game compatibility tiers from ProtonDB's public API to filter out
|
|
games that don't work well on Linux. Ratings are cached locally so repeated
|
|
lookups are free.
|
|
|
|
Tier hierarchy (best → worst): native, platinum, gold, silver, bronze, borked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from dataclasses import dataclass
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROTONDB_CACHE_FILE = CONFIG_DIR / "protondb_cache.json"
|
|
_PROTONDB_API = "https://www.protondb.com/api/v1/reports/summaries/{app_id}.json"
|
|
MAX_CONCURRENT = 30 # parallel requests - be polite to the CDN
|
|
|
|
HTTP_NOT_FOUND = 404
|
|
|
|
# Tier ordering from best to worst.
|
|
TIER_ORDER: dict[str, int] = {
|
|
"native": 0,
|
|
"platinum": 1,
|
|
"gold": 2,
|
|
"silver": 3,
|
|
"bronze": 4,
|
|
"borked": 5,
|
|
"pending": 6,
|
|
}
|
|
|
|
# Games at or below this tier are skipped.
|
|
MIN_PLAYABLE_TIER = "gold"
|
|
|
|
|
|
@dataclass
|
|
class ProtonDBRating:
|
|
"""ProtonDB compatibility rating for a game."""
|
|
|
|
app_id: int
|
|
tier: str = ""
|
|
trending_tier: str = ""
|
|
score: float = 0.0
|
|
confidence: str = ""
|
|
total_reports: int = 0
|
|
|
|
@property
|
|
def is_playable(self) -> bool:
|
|
"""True if the game has at least gold-tier compatibility.
|
|
|
|
A game is considered unplayable when:
|
|
- Its tier is silver, bronze, or borked.
|
|
- Both reported ratings are available, but one is below silver.
|
|
- Both reported ratings are available, but neither reaches gold.
|
|
|
|
If both ``tier`` and ``trending_tier`` exist, the acceptance rule is:
|
|
at least one rating must be gold-or-better and the other must be
|
|
silver-or-better.
|
|
"""
|
|
if not self.tier or self.tier == "pending":
|
|
return True # No data / pending → don't block; user can skip manually.
|
|
tier_rank = TIER_ORDER.get(self.tier, 99)
|
|
min_rank = TIER_ORDER[MIN_PLAYABLE_TIER]
|
|
silver_rank = TIER_ORDER["silver"]
|
|
|
|
if not self.trending_tier:
|
|
return tier_rank <= min_rank
|
|
|
|
trend_rank = TIER_ORDER.get(self.trending_tier, 99)
|
|
if tier_rank > silver_rank or trend_rank > silver_rank:
|
|
# Bronze, borked, unknown tier in either field → skip.
|
|
return False
|
|
|
|
# At least one rating must still be gold-or-better.
|
|
return not (tier_rank > min_rank and trend_rank > min_rank)
|
|
|
|
@property
|
|
def unplayable_reason(self) -> str:
|
|
"""Return a human-readable reason when ``is_playable`` is false."""
|
|
if self.is_playable:
|
|
return ""
|
|
|
|
tier_rank = TIER_ORDER.get(self.tier, 99)
|
|
silver_rank = TIER_ORDER["silver"]
|
|
|
|
if not self.trending_tier:
|
|
return f"tier<{MIN_PLAYABLE_TIER} ({self.tier})"
|
|
|
|
trend_rank = TIER_ORDER.get(self.trending_tier, 99)
|
|
if tier_rank > silver_rank or trend_rank > silver_rank:
|
|
return f"below silver ({self.tier}/{self.trending_tier})"
|
|
return f"no gold tier ({self.tier}/{self.trending_tier})"
|
|
|
|
|
|
def _load_cache() -> dict[str, Any]:
|
|
"""Load the on-disk ProtonDB cache."""
|
|
if PROTONDB_CACHE_FILE.exists():
|
|
data: dict[str, Any] = json.loads(
|
|
PROTONDB_CACHE_FILE.read_text(encoding="utf-8"),
|
|
)
|
|
return data
|
|
return {}
|
|
|
|
|
|
def _save_cache(cache: dict[str, Any]) -> None:
|
|
"""Persist the ProtonDB cache."""
|
|
_atomic_write(
|
|
PROTONDB_CACHE_FILE,
|
|
json.dumps(cache, indent=2) + "\n",
|
|
)
|
|
|
|
|
|
async def _fetch_one(
|
|
session: aiohttp.ClientSession,
|
|
sem: asyncio.Semaphore,
|
|
app_id: int,
|
|
) -> ProtonDBRating | None:
|
|
"""Fetch a single game's ProtonDB rating.
|
|
|
|
Returns None on network/server errors (not cached, will retry next run).
|
|
Returns ProtonDBRating with empty tier on HTTP 404 (no ProtonDB data).
|
|
"""
|
|
url = _PROTONDB_API.format(app_id=app_id)
|
|
async with sem:
|
|
try:
|
|
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as r:
|
|
if r.status == HTTP_NOT_FOUND:
|
|
return ProtonDBRating(app_id=app_id)
|
|
r.raise_for_status()
|
|
data = await r.json(content_type=None)
|
|
return ProtonDBRating(
|
|
app_id=app_id,
|
|
tier=data.get("tier", ""),
|
|
trending_tier=data.get("trendingTier", ""),
|
|
score=data.get("score", 0.0),
|
|
confidence=data.get("confidence", ""),
|
|
total_reports=data.get("total", 0),
|
|
)
|
|
except (TimeoutError, aiohttp.ClientError, OSError) as e:
|
|
logger.warning("ProtonDB fetch failed for AppID=%d: %s", app_id, e)
|
|
return None # Don't cache transient failures — retry next run.
|
|
|
|
|
|
async def _fetch_batch(app_ids: list[int]) -> list[ProtonDBRating]:
|
|
"""Fetch ProtonDB ratings for a batch of app IDs concurrently."""
|
|
sem = asyncio.Semaphore(MAX_CONCURRENT)
|
|
async with aiohttp.ClientSession() as session:
|
|
tasks = [_fetch_one(session, sem, aid) for aid in app_ids]
|
|
results = await asyncio.gather(*tasks)
|
|
return [r for r in results if r is not None]
|
|
|
|
|
|
def _rating_to_dict(r: ProtonDBRating) -> dict[str, Any]:
|
|
"""Serialize a rating to a cache-friendly dict."""
|
|
return {
|
|
"tier": r.tier,
|
|
"trending_tier": r.trending_tier,
|
|
"score": r.score,
|
|
"confidence": r.confidence,
|
|
"total_reports": r.total_reports,
|
|
}
|
|
|
|
|
|
def _rating_from_cache(app_id: int, data: dict[str, Any]) -> ProtonDBRating:
|
|
"""Deserialize a rating from cached data."""
|
|
return ProtonDBRating(
|
|
app_id=app_id,
|
|
tier=data.get("tier", ""),
|
|
trending_tier=data.get("trending_tier", ""),
|
|
score=data.get("score", 0.0),
|
|
confidence=data.get("confidence", ""),
|
|
total_reports=data.get("total_reports", 0),
|
|
)
|
|
|
|
|
|
def fetch_protondb_ratings(
|
|
app_ids: list[int],
|
|
) -> dict[int, ProtonDBRating]:
|
|
"""Fetch ProtonDB ratings with local caching.
|
|
|
|
Returns a dict mapping app_id → ProtonDBRating for every requested ID.
|
|
Cached results are reused; only missing IDs are fetched from the network.
|
|
"""
|
|
cache = _load_cache()
|
|
|
|
# Separate cached vs. uncached.
|
|
results: dict[int, ProtonDBRating] = {}
|
|
to_fetch: list[int] = []
|
|
for aid in app_ids:
|
|
key = str(aid)
|
|
if key in cache:
|
|
results[aid] = _rating_from_cache(aid, cache[key])
|
|
else:
|
|
to_fetch.append(aid)
|
|
|
|
if to_fetch:
|
|
logger.info(
|
|
"Fetching ProtonDB ratings for %d games (%d cached)...",
|
|
len(to_fetch),
|
|
len(results),
|
|
)
|
|
fetched = asyncio.run(_fetch_batch(to_fetch))
|
|
for r in fetched:
|
|
results[r.app_id] = r
|
|
cache[str(r.app_id)] = _rating_to_dict(r)
|
|
_save_cache(cache)
|
|
logger.info("ProtonDB: fetched %d, total cached %d", len(fetched), len(cache))
|
|
else:
|
|
logger.debug("All %d ProtonDB ratings found in cache.", len(results))
|
|
|
|
return results
|