steam-backlog-enforcer/steam_backlog_enforcer/_playtime_history.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

196 lines
7.0 KiB
Python

"""A durable per-day record of how much gaming time was actually spent.
``PlaytimeState`` only ever describes *today*: ``roll_over`` resets the counter
at the 06:00 boundary and the previous day is gone. The audit log in
``_playtime_log`` does retain per-day figures, but it is a rotating handler —
five 5 MB files — so a busy fortnight can push a day out of the window entirely.
Neither is a series you can plot.
So this module keeps one small file whose only job is the history: a
``day_key -> {seconds, games}`` mapping, trimmed to the last month, written
world-readable so an unprivileged reader (the web UI) can plot it. Schema 1
stored a bare seconds float per day and is still read: those days simply carry
no per-game breakdown, which the payload renders as Unattributed.
Writes are throttled rather than tied to roll-over. Upserting today's figure on a
cadence means the outgoing day is already recorded to within ``_FLUSH_DELTA``
seconds of its final value by the time the boundary arrives, which avoids
threading a "previous state" argument through the tick's hot path just to catch
one transition a day.
Like ``BudgetLog``, nothing here may raise: losing a history point is a cosmetic
failure and must never take down the enforcer.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._attribution_labels import labels_for
from steam_backlog_enforcer._playtime_history_parse import (
HistoryDay,
_parse_day,
_parse_labels,
)
from steam_backlog_enforcer._playtime_state import STATE_MODE
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
if TYPE_CHECKING:
from steam_backlog_enforcer._playtime_state import PlaytimeState
logger = logging.getLogger(__name__)
__all__ = ["HISTORY_FILE", "HistoryDay", "HistoryWriter", "load_history", "record_day"]
HISTORY_FILE: Final = CONFIG_DIR / "playtime_history.json"
_SCHEMA_VERSION: Final = 1
# Day values are polymorphic rather than versioned: a bare float is the
# original `day -> seconds` form, an object carries the per-game breakdown.
# The reader must handle both either way, so a version bump would add a
# branch that distinguishes nothing.
# Days retained. Comfortably more than the fortnight the UI plots, so a chart
# that later grows a longer window has data waiting for it.
_MAX_DAYS: Final = 30
# Rewrite only once the stored figure is this far behind. At a three-second tick
# an unthrottled upsert would rewrite the file 1200 times an hour to move a bar
# by a pixel.
_FLUSH_DELTA: Final = 60.0
def _read_file() -> tuple[dict[str, HistoryDay], dict[str, str]]:
"""Return the stored days and label map, or empties if unusable.
Unlike the state file this one is neither root-owned nor immutable, and it
lives in a user-owned directory — so an unprivileged process can replace it
with anything at all, and the web server parses whatever it finds. Every
field is therefore checked rather than trusted.
Returns:
The ``day -> HistoryDay`` mapping and the ``key -> label`` mapping, both
empty when the file is missing, unreadable, corrupt or of an unknown
schema.
"""
try:
raw = json.loads(HISTORY_FILE.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}, {}
except OSError, ValueError:
logger.warning(
"Playtime history at %s is unreadable; starting over.", HISTORY_FILE
)
return {}, {}
if not isinstance(raw, dict) or raw.get("schema_version") != _SCHEMA_VERSION:
logger.warning("Playtime history at %s has an unknown schema.", HISTORY_FILE)
return {}, {}
days = raw.get("days")
if not isinstance(days, dict):
return {}, {}
parsed = {
key: entry
for key, value in days.items()
if isinstance(key, str)
for entry in [_parse_day(key, value)]
if entry is not None
}
return parsed, _parse_labels(raw.get("labels"))
def load_history(limit: int = 14) -> list[HistoryDay]:
"""Return the most recent *limit* days, oldest first.
Args:
limit: How many days to return.
Returns:
Recorded days in ascending date order, at most *limit* of them.
"""
days, _ = _read_file()
ordered = sorted(days.items())[-limit:] if limit > 0 else []
return [entry for _, entry in ordered]
def load_labels() -> dict[str, str]:
"""Return the stored ``key -> label`` mapping.
Returns:
Labels recorded alongside the history.
"""
return _read_file()[1]
def record_day(day_key: str, seconds: float, games: dict[str, float]) -> None:
"""Upsert one day's total and breakdown, trimming to ``_MAX_DAYS``.
Labels are merged rather than replaced so a game that stops being played
keeps its name for as long as its days remain in the window.
Args:
day_key: Gaming day to record.
seconds: Total billed for that day so far.
games: Attribution key to seconds for that day.
"""
days, labels = _read_file()
days[day_key] = HistoryDay(day=day_key, seconds=seconds, games=dict(games))
trimmed = dict(sorted(days.items())[-_MAX_DAYS:])
labels.update(labels_for(games))
payload = {
"schema_version": _SCHEMA_VERSION,
"days": {
day: {"seconds": entry.seconds, "games": entry.games}
for day, entry in trimmed.items()
},
"labels": {
key: label
for key, label in labels.items()
if any(key in entry.games for entry in trimmed.values())
},
}
_atomic_write(
HISTORY_FILE,
json.dumps(payload, indent=2) + "\n",
mode=STATE_MODE,
)
class HistoryWriter:
"""Throttled writer for the per-day history, held across ticks.
Mirrors how ``TickJournal`` carries its heartbeat state on the session
rather than in a module global, which is what keeps the throttle testable
without reaching into module internals between cases.
"""
def __init__(self) -> None:
self._last_written: dict[str, float] = {}
def observe(self, state: PlaytimeState, *, demo: bool) -> None:
"""Record *state*'s day if it is new or has moved far enough.
Demo runs are skipped for the same reason they get their own state file
and their own audit log: a 60-second demo must not leave a record
describing a budget nobody was ever charged.
Args:
state: Current accounting state.
demo: Whether this is a demo run.
"""
if demo or not state.day_key:
return
previous = self._last_written.get(state.day_key)
if previous is not None and abs(state.seconds - previous) < _FLUSH_DELTA:
return
try:
record_day(state.day_key, state.seconds, state.per_game)
except OSError as exc:
# A history point is cosmetic; the enforcer keeps enforcing.
logger.warning("Could not record playtime history: %s", exc)
return
self._last_written = {state.day_key: state.seconds}