"""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], }