steam-backlog-enforcer/steam_backlog_enforcer/_achievement_skip.py
Krzysztof kuhy Rudnicki 401031602e Always allow games with zero achievements
Owned games Steam confirms have no achievements can never be assigned or
completed, so the enforcer no longer uninstalls, hides or kills them as
unauthorized. The gaming-time budget kill still applies to them.

Fail-closed: unknown status (no cache, other account, unreadable config
or snapshot, request error) keeps a game enforced. The set comes from
no_achievements_cache.json minus snapshot.json, so a game that gains
achievements is enforced again after the next scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWWAkVShjD2Bo6hM88B
2026-11-01 22:00:35 +01:00

204 lines
6.9 KiB
Python

"""Owned games known to have no achievements, so a library scan can skip them.
A scan is bound by the Steam request rate (``SteamAPIClient._max_rps``, one
request per owned game), and about a quarter of a typical library has no
achievements at all. A game is skipped only while all three hold:
- Steam does not flag it ``has_community_visible_stats``;
- it is not in the last snapshot (a game can have achievements without the
flag, so the flag alone is not trusted);
- a scan less than :data:`RECHECK_AFTER_SECONDS` ago found no achievements.
So a game that later gains achievements without gaining the flag is still
picked up, at the latest that long after.
The same cache doubles as the *always allowed* list (:func:`zero_achievement_ids`):
an owned game Steam answered "no achievements" for is never uninstalled, hidden
or killed as unauthorized. Only the daily budget still applies to it.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Any, Final
from steam_backlog_enforcer import config
from steam_backlog_enforcer._snapshot import load_snapshot
from steam_backlog_enforcer.config import _atomic_write
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
RECHECK_AFTER_SECONDS: Final = 30 * 86_400
_FILE_NAME: Final = "no_achievements_cache.json"
_NO_STATS_STATUS: Final = 400
def _cache_file() -> Path:
"""Resolved per call, so a redirected ``CONFIG_DIR`` is honoured."""
return config.CONFIG_DIR / _FILE_NAME
def _load_checked(steam_id: str) -> dict[int, float]:
"""Map app id -> when a scan last found it without achievements.
Returns:
An empty map when the file is missing, corrupt or for another account.
"""
try:
data = json.loads(_cache_file().read_text(encoding="utf-8"))
except OSError, ValueError:
return {}
if not isinstance(data, dict) or data.get("steam_id") != steam_id:
return {}
checked: dict[int, float] = {}
for key, when in dict(data.get("checked", {})).items():
try:
checked[int(key)] = float(when)
except TypeError, ValueError:
continue
return checked
def is_no_stats_answer(exc: Exception) -> bool:
"""Whether a failed achievements request was Steam saying "no stats".
``GetPlayerAchievements`` answers a game without stats with HTTP 400 and
``{"playerstats": {"error": "Requested app has no stats", "success":
false}}``. That is a real answer; any other failure (timeout, 5xx, 429)
says nothing about the game.
"""
response = getattr(exc.__cause__, "response", None)
if response is None or response.status_code != _NO_STATS_STATUS:
return False
try:
stats = response.json().get("playerstats", {})
except ValueError, AttributeError:
return False
return isinstance(stats, dict) and stats.get("success") is False
def _may_have_achievements(game: dict[str, Any]) -> bool:
"""Whether Steam's owned-games entry says the game has stats."""
return bool(game.get("has_community_visible_stats"))
def skippable_app_ids(
steam_id: str, owned: list[dict[str, Any]], now: float
) -> set[int]:
"""Return the owned games a scan may skip right now (see module doc).
Args:
steam_id: The account the cache must belong to.
owned: ``GetOwnedGames`` entries.
now: Current epoch seconds.
"""
checked = _load_checked(steam_id)
if not checked:
return set()
in_snapshot = {entry.get("app_id") for entry in load_snapshot() or []}
return {
game["appid"]
for game in owned
if not _may_have_achievements(game)
and game["appid"] not in in_snapshot
and now - checked.get(game["appid"], float("-inf")) < RECHECK_AFTER_SECONDS
}
def record_scan(
steam_id: str,
fetched: list[dict[str, Any]],
with_achievements: set[int],
now: float,
) -> None:
"""Remember which fetched games came back without achievements.
Never raises: losing the cache only costs the next scan its shortcut.
Args:
steam_id: The account the cache belongs to.
fetched: The owned-games entries this scan asked Steam about.
with_achievements: App ids that came back with achievements.
now: Current epoch seconds.
"""
checked = _load_checked(steam_id)
for game in fetched:
app_id = game["appid"]
if app_id in with_achievements or _may_have_achievements(game):
checked.pop(app_id, None)
else:
checked[app_id] = now
payload = {
"steam_id": steam_id,
"checked": {str(app_id): when for app_id, when in sorted(checked.items())},
}
try:
_atomic_write(_cache_file(), json.dumps(payload, indent=2) + "\n")
except OSError:
logger.warning("Could not save %s", _cache_file(), exc_info=True)
# (file stamps, ids) of the last derivation; the derivation reads three files.
_memo: dict[str, Any] = {}
def _stamp(path: Path) -> tuple[str, int, int]:
"""``(path, mtime_ns, size)``; zeros when absent, so a new file changes it."""
try:
st = path.stat()
except OSError:
return (str(path), 0, 0)
return (str(path), st.st_mtime_ns, st.st_size)
def _derive_zero_ids() -> frozenset[int]:
"""Cache keys for the configured account minus anything in the snapshot.
Fails closed: a missing or corrupt file, another account, an unknown
account or an unreadable snapshot all give the empty set.
"""
try:
steam_id = json.loads(config.CONFIG_FILE.read_text(encoding="utf-8"))[
"steam_id"
]
except OSError, ValueError, KeyError, TypeError:
return frozenset()
if not isinstance(steam_id, str) or not steam_id:
return frozenset()
confirmed = set(_load_checked(steam_id))
if not config.SNAPSHOT_FILE.exists():
return frozenset(confirmed)
try:
# Only games with achievements are ever snapshotted, so one that is in
# it contradicts the cache and is enforced.
in_snapshot = {int(entry["app_id"]) for entry in load_snapshot() or []}
except OSError, ValueError, KeyError, TypeError:
return frozenset()
return frozenset(confirmed - in_snapshot)
def zero_achievement_ids() -> frozenset[int]:
"""Owned games Steam confirmed have zero achievements: always allowed.
Presence in the cache is enough (no recheck age): only a scan that finds
achievements removes an entry, so the game is never dropped mid-session
for want of a rescan. Memoized on the files' stamps because the enforce
loop asks once per installed or running game every few seconds.
Returns:
The allowed app ids; empty whenever the status is unknown.
"""
key = tuple(
_stamp(path)
for path in (_cache_file(), config.CONFIG_FILE, config.SNAPSHOT_FILE)
)
if _memo.get("key") != key:
_memo["key"] = key
_memo["ids"] = _derive_zero_ids()
ids: frozenset[int] = _memo["ids"]
return ids