steam-backlog-enforcer/steam_backlog_enforcer/_owned_apps_cache.py
Krzysztof kuhy Rudnicki 1faf211767 Show which games make up the gaming budget, and bill osu!lazer
The budget tab answered "how much did I play" but not "what did I play":
playtime_state.json and playtime_history.json each stored one aggregate
seconds figure per gaming day, so the 14-day chart was a row of featureless
bars. Separately, qualifying_pids had exactly two sources — the SteamAppId
env var and a hardcoded 16-launcher frozenset — so osu!lazer, which sets no
SteamAppId and matches no launcher name, was never billed at all.

qualifying_pids now returns pid -> attribution key instead of a bare pid set.
The app id was already in hand and thrown away on its first line; it now
survives into PlaytimeState.per_game, alongside a user-editable
counted_processes list in config.json seeded with osu!lazer.

Two things the parts/whole relationship forces:

  - per_game only ever sums to AT MOST seconds, never equals it. backdate
    refunds the idle grace on every engaged-to-paused edge, and
    _state_or_recover synthesises a full budget with no breakdown. The
    refund is debited from last_credited_key, and the remainder is rendered
    as "Unattributed" rather than stored — one path then covers legacy days,
    focus-probe failures and drift alike.

  - counted_processes is kept apart from LAUNCHER_PROCESS_NAMES. That
    frozenset is also the total-block kill list, and it routes through
    kill_and_uninstall_launchers, which pacman -R's the owning package.
    Counted entries are killed on total block, never uninstalled.

Both storage formats stay at schema 1: load_state already filters to
dataclass fields, so a record without per_game migrates for free, and the
history reader has to accept both a bare float and an object regardless, so
a version bump would distinguish nothing.

Chart segments use the shared design system's categorical ramp (--cat-1..6),
capped at six games plus "Other" because a seventh distinguishable hue does
not exist under simulated CVD. The red over-budget fill is gone: colour now
encodes which game, and the dashed budget line alone says over or under.

Snapshot persistence moves to _snapshot.py to keep config.py under the
250-line cap. Three modules bound config paths at import time, which
bypasses the conftest redirect — they now reach them through the module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
2026-08-29 18:32:03 +02:00

118 lines
4.3 KiB
Python

"""Cached lookup of the Steam account's full owned-app list.
Split out of :mod:`steam_backlog_enforcer._enforce_loop` to keep both files
under the 250-line cap. The enforce loop runs every 3s but the owned-games
list changes rarely, so it is cached on disk with a TTL rather than re-fetched
from the Steam Web API each pass.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any
from steam_backlog_enforcer._snapshot import load_snapshot
from steam_backlog_enforcer.config import CONFIG_DIR, Config, _atomic_write
from steam_backlog_enforcer.steam_api import SteamAPIClient
logger = logging.getLogger(__name__)
_OWNED_IDS_CACHE_FILE = CONFIG_DIR / "owned_app_ids_cache.json"
_OWNED_IDS_CACHE_TTL_SECONDS = 3600
def _load_owned_app_ids_cache(steam_id: str) -> list[int] | None:
"""Return fresh cached owned app IDs for this steam_id, if available."""
if not steam_id or not _OWNED_IDS_CACHE_FILE.exists():
return None
try:
data: dict[str, Any] = json.loads(
_OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8")
)
except json.JSONDecodeError, OSError, ValueError:
return None
cached_steam_id = str(data.get("steam_id", ""))
if cached_steam_id != steam_id:
return None
fetched_at = float(data.get("fetched_at", 0.0) or 0.0)
age = time.time() - fetched_at
if age > _OWNED_IDS_CACHE_TTL_SECONDS:
return None
raw_ids = data.get("app_ids", [])
if not isinstance(raw_ids, list):
return None
return [int(app_id) for app_id in raw_ids]
def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None:
"""Persist owned app IDs cache for this steam_id."""
payload = {
"steam_id": steam_id,
"fetched_at": time.time(),
"app_ids": app_ids,
}
_atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n")
# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────
def get_all_owned_app_ids(config: Config) -> list[int]:
"""Get all owned game app IDs from Steam API plus snapshot fallback.
Snapshot data contains only games with achievements, so API data is the
primary source for library hiding. Snapshot IDs are merged in to keep
behavior resilient when the API result is partial.
"""
snapshot = load_snapshot() or []
snapshot_ids = [int(d["app_id"]) for d in snapshot if "app_id" in d]
cached_ids = _load_owned_app_ids_cache(config.steam_id)
if cached_ids is not None:
merged_ids: list[int] = []
seen: set[int] = set()
for app_id in [*cached_ids, *snapshot_ids]:
if app_id in seen:
continue
seen.add(app_id)
merged_ids.append(app_id)
logger.info("Using cached Steam owned IDs (%d entries).", len(cached_ids))
return merged_ids
try:
client = SteamAPIClient(config.steam_api_key, config.steam_id)
owned = client.get_owned_games()
api_ids = [int(g["appid"]) for g in owned if "appid" in g]
_save_owned_app_ids_cache(config.steam_id, api_ids)
merged_ids: list[int] = []
seen: set[int] = set()
for app_id in [*api_ids, *snapshot_ids]:
if app_id in seen:
continue
seen.add(app_id)
merged_ids.append(app_id)
except OSError, RuntimeError, ValueError:
if snapshot_ids:
return snapshot_ids
logger.warning("Could not fetch owned game list for hiding.")
return []
else:
return merged_ids
# ──────────────────────────────────────────────────────────────
# Enforce mode (daemon loop)
# ──────────────────────────────────────────────────────────────
# How often the enforce loop runs (seconds).