mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
pick and pick-manual no longer uninstall games that are already allowed: the outgoing assignment is kept as a released pick (installed, no lock) until 'pick --replace', done/check, or abandon-pick. The allowed set is capped at max_manual_picks + 1; a pick over it is refused before the candidate list / confirmation, naming the games. status and the web Picks card list kept games with an Abandon button. Fast install now tracks the installer log from the spawn offset: web jobs drive the progress bar (per-depot %, phase) and echo a line every 30 s; daemon-started runs are exposed via ctl status / /api/daemon and shown on the Dashboard. Also: web deps bumped for dependency freshness (react-query 5.104.5, typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
163 lines
5.9 KiB
Python
163 lines
5.9 KiB
Python
"""Abandonment and the status payload for manual picks.
|
|
|
|
Split out of :mod:`steam_backlog_enforcer._actions` to keep both files
|
|
under the 250-line cap.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from steam_backlog_enforcer._actions import (
|
|
find_manual_pick,
|
|
is_manual_pick_locked,
|
|
)
|
|
from steam_backlog_enforcer._allowed_games import (
|
|
active_manual_picks,
|
|
allowed_games,
|
|
)
|
|
from steam_backlog_enforcer._assignment_progress import record_assignment
|
|
from steam_backlog_enforcer._total_block import get_total_block_status
|
|
from steam_backlog_enforcer.game_install import get_installed_games, is_protected_app
|
|
from steam_backlog_enforcer.store_blocker import is_store_blocked
|
|
|
|
# Marks these two as an intentional re-export (they're imported from
|
|
# _allowed_games above, not defined here) -- without this, mypy's
|
|
# --no-implicit-reexport flags every downstream `from _actions import
|
|
# active_manual_picks` as an error. Everything else in this file is
|
|
# accessible normally; this only needs to list names imported-then-reexported.
|
|
__all__ = [
|
|
"active_manual_picks",
|
|
"allowed_games",
|
|
]
|
|
|
|
if TYPE_CHECKING:
|
|
from steam_backlog_enforcer.config import State
|
|
|
|
# How long an abandoned pick stays out of the auto-assignment pool, so that
|
|
# ``scan`` does not immediately hand back the game the user just rejected.
|
|
ABANDON_COOLDOWN_DAYS = 30
|
|
|
|
|
|
def manual_pick_age_days(state: State, app_id: int) -> float | None:
|
|
"""Return how many days ago *app_id* was picked, or ``None``.
|
|
|
|
Informational only — picks can be abandoned at any age. ``None`` means the
|
|
question does not apply: *app_id* is not an active manual pick, or its
|
|
timestamp is missing/malformed.
|
|
|
|
Args:
|
|
state: The loaded enforcer state.
|
|
app_id: The manually-picked app id to measure.
|
|
|
|
Returns:
|
|
Fractional days elapsed since the pick was made, or ``None`` when the
|
|
question does not apply.
|
|
"""
|
|
pick = find_manual_pick(state, app_id)
|
|
if pick is None or not pick.get("started_at"):
|
|
return None
|
|
try:
|
|
started = datetime.fromisoformat(pick["started_at"])
|
|
except ValueError:
|
|
return None
|
|
return (datetime.now(UTC) - started).total_seconds() / 86400
|
|
|
|
|
|
def abandon_manual_pick(state: State, app_id: int) -> bool:
|
|
"""Drop one manual pick and persist ``state``.
|
|
|
|
A pick may be abandoned at any time, however long ago it was made, and
|
|
also after it was released or expired (it is still installed then).
|
|
|
|
Only the named pick is dropped: any other active pick keeps its own lock
|
|
and deadline. The abandoned app id goes onto the existing skip cooldown so
|
|
``scan`` will not hand the same game straight back. State-only: like
|
|
``apply_manual_pick`` this performs no uninstall/hide cascade, so the MCP
|
|
server can call it without touching the filesystem.
|
|
|
|
Args:
|
|
state: The enforcer state to mutate and save.
|
|
app_id: The manually-picked app id to back out of.
|
|
|
|
Returns:
|
|
``True`` if the pick was abandoned, ``False`` if it is not an active
|
|
pick, in which case ``state`` is untouched.
|
|
"""
|
|
if all(p.get("app_id") != app_id for p in state.manual_picks):
|
|
return False
|
|
|
|
state.skip_for_days(app_id, ABANDON_COOLDOWN_DAYS)
|
|
state.manual_picks = [p for p in state.manual_picks if p.get("app_id") != app_id]
|
|
|
|
# The abandoned pick may also have been the current assignment; hand the
|
|
# assignment to a surviving pick so the enforcer keeps guarding it, or
|
|
# clear it so 'scan' can reassign.
|
|
if state.current_app_id == app_id:
|
|
survivors = active_manual_picks(state) or state.manual_picks
|
|
survivor = survivors[-1] if survivors else None
|
|
if survivor is None:
|
|
state.current_app_id = None
|
|
state.current_game_name = ""
|
|
state.current_assigned_at = ""
|
|
else:
|
|
# The survivor has been assigned since it was picked.
|
|
record_assignment(
|
|
state,
|
|
survivor["app_id"],
|
|
survivor["game_name"],
|
|
at=survivor.get("started_at") or None,
|
|
)
|
|
|
|
state.save()
|
|
return True
|
|
|
|
|
|
def status_payload(state: State) -> dict[str, Any]:
|
|
"""Build the structured status snapshot that ``cmd_status`` renders as text.
|
|
|
|
Pure data, no stdout — safe for the MCP server. Reads only ``State`` and the
|
|
stdout-free leaf helpers; never constructs ``Config`` and never exposes the
|
|
Steam API key.
|
|
|
|
Args:
|
|
state: The loaded enforcer state.
|
|
|
|
Returns:
|
|
A JSON-ready dict describing the current enforcement status.
|
|
"""
|
|
total_block = get_total_block_status()
|
|
installed = get_installed_games()
|
|
real_games = [(aid, name) for aid, name in installed if not is_protected_app(aid)]
|
|
assigned_installed = (
|
|
any(aid == state.current_app_id for aid, _ in installed)
|
|
if state.current_app_id
|
|
else None
|
|
)
|
|
active = active_manual_picks(state)
|
|
return {
|
|
"current_app_id": state.current_app_id,
|
|
"current_game_name": state.current_game_name or None,
|
|
"finished_count": len(state.finished_app_ids),
|
|
"store_blocked": is_store_blocked(),
|
|
"installed_count": len(real_games),
|
|
"assigned_game_installed": assigned_installed,
|
|
"total_block": {
|
|
"active": total_block.active,
|
|
"days_remaining": round(total_block.days_remaining, 1),
|
|
"until": total_block.until.isoformat() if total_block.until else None,
|
|
},
|
|
"manual_pick_locked": is_manual_pick_locked(state),
|
|
"manual_picks": [
|
|
{
|
|
**pick,
|
|
"age_days": manual_pick_age_days(state, pick["app_id"]),
|
|
}
|
|
for pick in active
|
|
],
|
|
# Released, expired or parked by 'pick': still allowed and installed,
|
|
# but holding no lock; 'abandon-pick' removes them.
|
|
"kept_picks": [{**p} for p in state.manual_picks if p not in active],
|
|
}
|