mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 12:47:32 +01:00
The enforcer now imports the Steam/launcher/counted-process scan from leisure_watch.procscan (v0.1.0, shared with the leisure watcher) and drops its own copies (_playtime_procs, _counted_procs, _total_block_launchers and enforcer's two scanners). Every counted-process read passes config.CONFIG_FILE explicitly, as before. Each tick also bills a shadow leisure budget beside the enforced one, from the same incoming stamp and delta clamp, before accumulate: paused (held gatelock grab or i3lock) -> shadow_paused_seconds; else a qualifying game, a "leisure" now.json, or an untrusted/missing now.json (fail closed) bills shadow_seconds once, split over shadow_per_source. shadow_budget_seconds is earned_time.resolve(leisure=True) from the same answers, held as a same-day high-water mark. Not billed under a total block. Shadow verdict changes go to budget.jsonl as shadow_change (the live game view skips them); /api/budget and MCP get_gaming_time carry a "shadow" block. Enforced fields, cutoff, warnings and existing log records are unchanged. Also fixes two tests that assumed earned_time < 0.14 internals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
94 lines
2.8 KiB
Python
94 lines
2.8 KiB
Python
"""Turning attribution keys into names a human recognises.
|
|
|
|
Keys are stable (``app:475150``); labels are not (Steam renames games, and a
|
|
user can retitle a ``counted_processes`` entry). So keys are what get stored in
|
|
state and history, and this module resolves them for display.
|
|
|
|
Steam names come from ``appmanifest_<id>.acf`` rather than ``snapshot.json``:
|
|
a game that is *running* is by definition installed, so its manifest exists and
|
|
is a few hundred bytes, whereas the snapshot is ~11 MB and would be read on
|
|
every three-second tick and every five-second UI poll.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import re
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from leisure_watch.procscan import labels_by_key, load_counted_processes
|
|
|
|
from steam_backlog_enforcer import _steam_state, config
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
_APP_PREFIX: Final = "app:"
|
|
_PROC_PREFIX: Final = "proc:"
|
|
_LAUNCHER_PREFIX: Final = "launcher:"
|
|
|
|
# Same pattern game_uninstall.get_installed_games() uses on the same files.
|
|
_NAME_RE: Final = re.compile(r'"name"\s+"([^"]+)"')
|
|
|
|
# Manifest names never change while a game runs, and the daemon is long-lived.
|
|
_app_names: dict[int, str] = {}
|
|
|
|
|
|
def _steam_name(app_id: int) -> str | None:
|
|
"""Return *app_id*'s name from its appmanifest, or ``None``.
|
|
|
|
Args:
|
|
app_id: Steam application id.
|
|
|
|
Returns:
|
|
The manifest's ``name`` field, or ``None`` if unreadable.
|
|
"""
|
|
cached = _app_names.get(app_id)
|
|
if cached is not None:
|
|
return cached
|
|
manifest = _steam_state.STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
|
|
with contextlib.suppress(OSError):
|
|
match = _NAME_RE.search(manifest.read_text(encoding="utf-8"))
|
|
if match:
|
|
name = match.group(1)
|
|
_app_names[app_id] = name
|
|
return name
|
|
return None
|
|
|
|
|
|
def label_for(key: str) -> str:
|
|
"""Return a display name for an attribution *key*.
|
|
|
|
Falls back to the key itself, which is ugly but never wrong — an
|
|
unresolvable game is still better identified by ``app:475150`` than by a
|
|
blank row.
|
|
|
|
Args:
|
|
key: An ``app:``/``proc:``/``launcher:`` attribution key.
|
|
|
|
Returns:
|
|
The display label.
|
|
"""
|
|
if key.startswith(_APP_PREFIX):
|
|
raw = key[len(_APP_PREFIX) :]
|
|
if raw.isdigit():
|
|
return _steam_name(int(raw)) or key
|
|
return key
|
|
if key.startswith(_PROC_PREFIX):
|
|
return labels_by_key(load_counted_processes(config.CONFIG_FILE)).get(key, key)
|
|
if key.startswith(_LAUNCHER_PREFIX):
|
|
return key[len(_LAUNCHER_PREFIX) :]
|
|
return key
|
|
|
|
|
|
def labels_for(keys: Iterable[str]) -> dict[str, str]:
|
|
"""Resolve every key in *keys* to its label.
|
|
|
|
Args:
|
|
keys: Any iterable of attribution keys.
|
|
|
|
Returns:
|
|
Mapping of key to display label.
|
|
"""
|
|
return {key: label_for(key) for key in keys}
|