steam-backlog-enforcer/steam_backlog_enforcer/main/install.py
Krzysztof kuhy Rudnicki c31ae2170f fix: fast install ignores other downloads; install offers any allowed game
- Fast install no longer refuses while Steam downloads something else:
  the installer leaves via `steam -shutdown`, which Steam resumes from.
  Only a running game still refuses.
- Non-daemon detached runs (web jobs) wait for the installer with the
  same stall reaper the daemon uses, and log to a per-uid file: the
  shared root-owned log made every job spawn raise.
- `install` offers every allowed game that is not fully installed
  (assignment + manual picks), so a skipped fast install can be started
  later; it takes over Steam's slow download.
- Uninstall progress names only removed games; protected runtimes are
  logged as kept instead of appearing as the last "uninstalled" item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fHzMuPSHq68S2AcPqGe3Q
2026-10-16 15:21:00 +02:00

165 lines
5.5 KiB
Python

"""Install, uninstall and library-visibility commands."""
from __future__ import annotations
from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer._prompter import (
PromptOption,
confirm_phrase,
current_prompter,
)
from steam_backlog_enforcer._steam_errors import SteamUnavailableError
from steam_backlog_enforcer._steam_state import is_game_fully_installed
from steam_backlog_enforcer.game_install import (
_echo,
get_installed_games,
install_game,
is_protected_app,
uninstall_other_games,
)
from steam_backlog_enforcer.library_hider import (
try_hide_other_games,
unhide_all_games,
)
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State
def cmd_installed(_config: Config, state: State) -> None:
"""Show installed games."""
installed = get_installed_games()
_echo(f"\nInstalled games ({len(installed)}):\n")
for app_id, name in installed:
protected = " [PROTECTED]" if is_protected_app(app_id) else ""
assigned = " <<< ASSIGNED" if app_id == state.current_app_id else ""
_echo(f" {app_id:>8d} {name}{protected}{assigned}")
def cmd_uninstall(_config: Config, state: State) -> None:
"""Uninstall all games except the assigned one."""
if state.current_app_id is None:
_echo("No game assigned. Run 'scan' first.")
return
allowed = allowed_app_ids(state)
installed = get_installed_games()
to_remove = [
(aid, n)
for aid, n in installed
if aid not in allowed and not is_protected_app(aid)
]
if not to_remove:
_echo("No games to uninstall (only allowed games and runtimes installed).")
return
_echo(f"\nWill uninstall {len(to_remove)} games, keeping:")
for aid, name in allowed_games(state):
_echo(f" - {name} (AppID={aid})")
_echo(" - Steam runtimes and Proton versions\n")
_echo("Games to remove:")
for aid, name in to_remove:
_echo(f" - {name} (AppID={aid})")
_echo()
if not confirm_phrase(
"uninstall", f"Uninstall {len(to_remove)} games?", count=len(to_remove)
):
_echo("Aborted.")
return
count = uninstall_other_games(allowed_app_ids(state))
_echo(f"\nUninstalled {count} games.")
def _pick_install_target(state: State) -> tuple[int, str] | None:
"""The allowed game to install: the only unfinished one, or the user's pick.
Every allowed game counts, not just the assignment: a manual pick whose
fast install was skipped (a game was running at pick time) needs a way to
start it later too.
"""
pending = [
(aid, n) for aid, n in allowed_games(state) if not is_game_fully_installed(aid)
]
if not pending:
_echo("All allowed games are already installed.")
return None
if len(pending) == 1:
return pending[0]
options = [PromptOption(str(aid), name or f"AppID={aid}") for aid, name in pending]
picked = int(current_prompter().choice("Install which game", options))
return next(game for game in pending if game[0] == picked)
def cmd_install(config: Config, state: State) -> None:
"""Install an allowed game, through the fast installer when it can be used.
A download Steam already started is taken over, so this is also how a
fast install that was skipped earlier gets started.
"""
if not allowed_games(state):
_echo("No game currently assigned. Run 'scan' first.")
return
target = _pick_install_target(state)
if target is None:
return
app_id, name = target
_echo(f"Installing {name} (AppID={app_id})...")
# install_game only *starts* the download (fast installer or steam://);
# there is no download-progress loop to report on yet.
current_progress().phase(f"Starting install of {name}")
if install_game(app_id, name, config.steam_id, use_steam_protocol=True):
_echo("Done!")
else:
_echo("Failed to start the install.")
def cmd_hide(config: Config, state: State) -> None:
"""Hide all non-assigned games in the Steam library."""
if state.current_app_id is None:
_echo("No game assigned. Run 'scan' first.")
return
owned_ids = get_all_owned_app_ids(config)
if not owned_ids:
_echo("No owned game list available. Run 'scan' first.")
return
_echo(f"Hiding all games except {state.current_game_name}...")
hidden, skipped = try_hide_other_games(owned_ids, allowed_app_ids(state))
if skipped is not None:
_echo(f"Library hiding: skipped ({skipped})")
return
_echo(f"Hidden {hidden} games.")
if hidden > 0:
_echo("Done! Only the assigned game should be visible in your library.")
def cmd_unhide(config: Config, _state: State) -> None:
"""Unhide all games in the Steam library."""
owned_ids = get_all_owned_app_ids(config)
if not owned_ids:
_echo("No owned game list available. Run 'scan' first.")
return
_echo("Unhiding all games...")
# Same contract as cmd_hide: no reachable Steam means nothing to change
# this time, not a failed command.
try:
count = unhide_all_games(owned_ids)
except SteamUnavailableError as exc:
_echo(f"Library unhiding: skipped ({exc})")
return
_echo(f"Unhidden {count} games.")
if count > 0:
_echo("Done!")