steam-backlog-enforcer/steam_backlog_enforcer/scanning.py
Krzysztof kuhy Rudnicki 57d262b711
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Pylint gate 10 over the whole package
Raise the pylint hook to --fail-under=10.0 and make it lint the whole
package on every run (pass_filenames: false): over the staged subset a
9.99 tree passed at commit time.

Ports de2aea3 (9b9342ba-1) onto main, minus its web/ and requirements.txt
bumps and the iptables dedupe f1822f1 already landed; _hltb_confidence and
_hltb_search keep main's 429 handling. _search_batch splits out
_acquire_auth / _search_headers, and _stats.py is back to 250 lines.

Drops the min-similarity-lines = 8 floor (0 duplicate-code hits at the
default) and sets max-attributes = 15 for the flat persisted records, as
kuhy decided. Tests stay outside pylint, as at every site already at 10.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:57:35 +01:00

244 lines
8.7 KiB
Python

"""Game scanning, selection, checking, and enforcement daemon."""
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_app_ids
from steam_backlog_enforcer._assignment_progress import (
release_game,
release_verdict,
)
from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached
from steam_backlog_enforcer._hltb_types import (
load_hltb_count_comp_cache,
load_hltb_polls_cache,
)
from steam_backlog_enforcer._pick_completion import report_completion
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer._scanning_assign import (
_NO_CONF_MSG,
_assign_chosen_game,
_clear_assignment,
_no_pick_message,
_open_candidates,
_pick_next_game_sequential,
_prompt_user_pick,
)
from steam_backlog_enforcer._scanning_candidates import (
_collect_qualified_candidates,
_collect_top_candidates,
_pick_next_shortest_candidate,
_pick_playable_candidate,
)
from steam_backlog_enforcer._scanning_confidence import (
_report_poll_confidence,
)
from steam_backlog_enforcer._scanning_tampering import (
_check_game_tampering,
detect_tampering,
)
from steam_backlog_enforcer._snapshot import load_snapshot, save_snapshot
from steam_backlog_enforcer.enforcer import (
send_notification,
)
from steam_backlog_enforcer.game_install import (
_echo,
)
from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
if TYPE_CHECKING:
from collections.abc import Callable
from steam_backlog_enforcer.config import Config, State
# These helpers moved to the _scanning_* leaf modules, but callers and tests
# have always imported them from here. mypy's --no-implicit-reexport needs
# them listed explicitly.
__all__ = [
"_check_game_tampering",
"_collect_top_candidates",
"_pick_next_shortest_candidate",
"_pick_playable_candidate",
"detect_tampering",
]
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────
# Scanning & game selection
# ──────────────────────────────────────────────────────────────
def do_scan(config: Config, state: State) -> list[GameInfo]:
"""Full library scan: Steam API + HLTB times."""
client = SteamAPIClient(config.steam_api_key, config.steam_id)
start = time.time()
def progress(current: int, total: int) -> None:
if current % 50 == 0 or current == total:
_echo(f"\r Scanning achievements: {current}/{total}", end="", flush=True)
_echo("Scanning Steam library...")
games = client.build_game_list(
progress_callback=progress,
)
elapsed = time.time() - start
_echo(f"\n Scanned {len(games)} games with achievements in {elapsed:.1f}s")
# Fetch HLTB times (cached).
incomplete = [(g.app_id, g.name) for g in games if not g.is_complete]
if incomplete:
_echo(f"Fetching HLTB completion times for {len(incomplete)} games...")
def hltb_progress(done: int, total: int, found: int, name: str) -> None:
pct = done * 100 // total
bar_w = 30
filled = bar_w * done // total
meter = "█" * filled + "░" * (bar_w - filled)
_echo(
f"\r HLTB [{meter}] {done}/{total} ({pct}%) "
f"| {found} found | {name[:30]:<30s}",
end="",
flush=True,
)
hltb_cache = fetch_hltb_times_cached(incomplete, progress_cb=hltb_progress)
_echo("") # newline after progress bar
polls_cache = load_hltb_polls_cache()
count_comp_cache = load_hltb_count_comp_cache()
for g in games:
g.completionist_hours = hltb_cache.get(g.app_id, -1)
g.comp_100_count = polls_cache.get(g.app_id, 0)
g.count_comp = count_comp_cache.get(g.app_id, 0)
_echo(
f" HLTB data: {sum(1 for h in hltb_cache.values() if h > 0)} games"
" have completion estimates"
)
# Save snapshot.
save_snapshot([g.to_snapshot() for g in games])
n_complete = sum(1 for g in games if g.is_complete)
_echo(f"\nResults: {n_complete} complete, {len(games) - n_complete} incomplete")
# Auto-pick a game if none assigned.
if state.current_app_id is None:
pick_next_game(games, state, config)
else:
# Show confidence info for the already-assigned game too.
current = next(
(g for g in games if g.app_id == state.current_app_id),
None,
)
if current is not None:
_echo(f"\n>>> CURRENT: {current.name} (AppID={current.app_id})")
_report_poll_confidence(current, games, state)
return games
# How many candidates to check per ProtonDB batch.
def pick_next_game(
games: list[GameInfo],
state: State,
config: Config,
*,
on_select: Callable[[GameInfo], bool | GameInfo] | None = None,
keep: bool = False,
) -> None:
"""Present a ranked list of eligible games and let the user pick one.
Games are ranked least-recently-assigned first, then by shortest
completionist time. Games with
silver-or-worse ProtonDB ratings (or gold trending downward) are
excluded as unplayable on Linux.
If ``on_select`` is provided, the legacy 10-candidate picker is
bypassed: the function instead presents the shortest playable
candidate to ``on_select`` (typically a yes/no prompt) and, if the
callback rejects it, records a 7-day skip and re-evaluates.
``keep`` (plain ``pick``) adds the chosen game next to the ones already
allowed instead of replacing them, so those are not offered again. The
sequential path (``done``/``check``) always replaces.
"""
if on_select is not None:
_pick_next_game_sequential(games, state, config, on_select)
return
# Offering an allowed game in keep mode would be a no-op "pick"; the
# ranked list and the own-game search both draw from this pool.
allowed = allowed_app_ids(state) if keep else set()
pool = [g for g in games if g.app_id not in allowed]
candidates = _open_candidates(pool, state)
if not candidates:
_clear_assignment(state, _NO_CONF_MSG)
return
qualified, confidence_skipped, linux_skipped = _collect_qualified_candidates(
candidates
)
if not qualified:
_clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped))
return
chosen = _prompt_user_pick(qualified, pool, state)
_assign_chosen_game(chosen, games, state, config, keep=keep)
# ──────────────────────────────────────────────────────────────
# Checking & tampering detection
# ──────────────────────────────────────────────────────────────
def do_check(config: Config, state: State) -> None:
"""Check the assigned game for a new achievement; detect tampering."""
report_completion(config, state)
if state.current_app_id is None:
_echo("No game currently assigned. Run 'scan' first.")
return
client = SteamAPIClient(config.steam_api_key, config.steam_id)
_echo(f"Checking {state.current_game_name} (AppID={state.current_app_id})...")
progress = current_progress()
progress.phase("Fetching achievements from Steam", 1)
game = client.refresh_single_game(state.current_app_id, state.current_game_name)
progress.advance(state.current_game_name)
if game is None:
_echo(" Could not fetch achievement data.")
return
_echo(
f" Progress: {game.unlocked_achievements}/{game.total_achievements}"
f" ({game.completion_pct:.1f}%)"
)
released, verdict = release_verdict(state, game)
_echo(f"\n {verdict}")
if released:
release_game(state, game)
send_notification(
"Moving on!",
f"New achievement in {state.current_game_name}! Picking next game...",
)
# Load snapshot and pick next.
snapshot_data = load_snapshot()
if snapshot_data:
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
pick_next_game(games, state, config)
else:
state.save()
_echo(" Run 'scan' to pick the next game.")
# Tampering detection on snapshot.
detect_tampering(config, state)