mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 14:07:33 +01:00
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
196 lines
7.0 KiB
Python
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}
|