mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
hltb, _cmd_done, _whitelist and _actions each kept a module __getattr__ that served names moved out in the 250-line split, via importlib to dodge the resulting import cycle. pylint cannot see through that, and graded every such import as no-name-in-module (26 of them). The 32 import statements now name the real module; with nothing left going through the facades, they are gone. Also the pylint hook on 4.0.8 with the source-only scope config, the three implicit-booleaness disables the reference repos carry, the http.server do_GET admission, and the third-party pins the hook was missing (aiohttp, websockets, howlongtobeatpy). Gate still 8.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
223 lines
7.4 KiB
Python
223 lines
7.4 KiB
Python
"""Game selection and HLTB/completion data top-ups for ``stats``.
|
|
|
|
Split out of :mod:`steam_backlog_enforcer._stats` to keep both files under
|
|
the 250-line cap. Leaf helpers: nothing here calls back into ``_stats``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from steam_backlog_enforcer._hltb_confidence import fetch_hltb_detail_missing
|
|
from steam_backlog_enforcer._hltb_types import (
|
|
load_hltb_cache,
|
|
load_hltb_game_id_cache,
|
|
load_hltb_leisure_100h_cache,
|
|
load_hltb_rush_cache,
|
|
)
|
|
from steam_backlog_enforcer._scanning_confidence import (
|
|
_apply_cached_confidence_to_candidates,
|
|
_confidence_fail_reasons,
|
|
_refresh_candidate_confidence_batch,
|
|
)
|
|
from steam_backlog_enforcer._stats_types import _GameTimes
|
|
from steam_backlog_enforcer.config import SNAPSHOT_FILE
|
|
from steam_backlog_enforcer.game_install import _echo
|
|
from steam_backlog_enforcer.protondb import (
|
|
ProtonDBRating,
|
|
fetch_protondb_ratings,
|
|
)
|
|
from steam_backlog_enforcer.steam_api import (
|
|
GameInfo,
|
|
SteamAPIClient,
|
|
SteamAPIError,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from steam_backlog_enforcer.config import Config, State
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _filter_qualifying_games(
|
|
games: list[GameInfo],
|
|
state: State,
|
|
) -> tuple[list[_GameTimes], int, int, int]:
|
|
"""Return qualifying incomplete games with their time estimates.
|
|
|
|
Applies the same HLTB-confidence and Linux-compatibility filters as the
|
|
game picker. The current game and already-finished games are excluded.
|
|
|
|
Returns:
|
|
(qualified_list, hltb_skipped, linux_skipped, no_data_skipped)
|
|
"""
|
|
rush_cache = load_hltb_rush_cache()
|
|
leisure_100h_cache = load_hltb_leisure_100h_cache()
|
|
game_id_cache = load_hltb_game_id_cache()
|
|
hours_cache = load_hltb_cache()
|
|
|
|
exclude = set(state.finished_app_ids)
|
|
if state.current_app_id is not None:
|
|
exclude.add(state.current_app_id)
|
|
|
|
candidates = [g for g in games if not g.is_complete and g.app_id not in exclude]
|
|
_apply_cached_confidence_to_candidates(candidates)
|
|
_refresh_candidate_confidence_batch(candidates)
|
|
|
|
hltb_skipped = 0
|
|
linux_skipped = 0
|
|
no_data_skipped = 0
|
|
app_ids_to_check: list[int] = []
|
|
|
|
conf_ok: list[GameInfo] = []
|
|
for game in candidates:
|
|
if _confidence_fail_reasons(game):
|
|
hltb_skipped += 1
|
|
continue
|
|
conf_ok.append(game)
|
|
app_ids_to_check.append(game.app_id)
|
|
|
|
ratings: dict[int, ProtonDBRating] = {}
|
|
if app_ids_to_check:
|
|
ratings = fetch_protondb_ratings(app_ids_to_check)
|
|
|
|
qualified: list[_GameTimes] = []
|
|
for game in conf_ok:
|
|
rating = ratings.get(game.app_id, ProtonDBRating(app_id=game.app_id))
|
|
if not rating.is_playable:
|
|
linux_skipped += 1
|
|
continue
|
|
|
|
rush = rush_cache.get(game.app_id, -1)
|
|
leisure = leisure_100h_cache.get(game.app_id, -1)
|
|
|
|
# worst_hours = max of: snapshot completionist, HLTB hours cache (fallback
|
|
# when snapshot is stale/missing), and leisure_100h (slowest 100% time).
|
|
snap_hours = game.completionist_hours if game.completionist_hours > 0 else -1
|
|
cache_hours = hours_cache.get(game.app_id, -1)
|
|
worst_candidates = [v for v in (snap_hours, cache_hours, leisure) if v > 0]
|
|
worst = max(worst_candidates) if worst_candidates else -1
|
|
|
|
if worst <= 0 and rush <= 0 and leisure <= 0:
|
|
no_data_skipped += 1
|
|
continue
|
|
|
|
qualified.append(
|
|
_GameTimes(
|
|
game=game,
|
|
worst_hours=worst,
|
|
rush_hours=rush,
|
|
leisure_100h=leisure,
|
|
hltb_game_id=game_id_cache.get(game.app_id, 0),
|
|
)
|
|
)
|
|
|
|
return qualified, hltb_skipped, linux_skipped, no_data_skipped
|
|
|
|
|
|
def _ensure_rush_data(qualified: list[_GameTimes]) -> bool:
|
|
"""Auto-fetch rush/leisure detail for games that are missing it.
|
|
|
|
Returns True when a fetch was performed; the caller should then re-run
|
|
``_filter_qualifying_games`` to pick up the updated caches.
|
|
"""
|
|
total_q = len(qualified)
|
|
missing = sum(1 for e in qualified if e.rush_hours <= 0)
|
|
if not qualified or not missing:
|
|
return False
|
|
_echo(f"Fetching HLTB detail for {missing}/{total_q} games missing rush/leisure...")
|
|
game_pairs = [(e.game.app_id, e.game.name) for e in qualified]
|
|
fetch_hltb_detail_missing(game_pairs)
|
|
return True
|
|
|
|
|
|
def _ensure_completed_rush_data(games: list[GameInfo]) -> bool:
|
|
"""Fetch rush/leisure detail for completed games used for pace calibration.
|
|
|
|
Completed games aren't processed by ``_ensure_rush_data`` (which only
|
|
handles incomplete qualifying games), so this separate pass fills in
|
|
their rush/leisure data for ``compute_pace_vs_hltb``.
|
|
|
|
Returns True when at least one new fetch was performed.
|
|
"""
|
|
pairs = [
|
|
(g.app_id, g.name) for g in games if g.is_complete and g.playtime_minutes > 0
|
|
]
|
|
if not pairs:
|
|
return False
|
|
_echo(
|
|
f"Fetching HLTB detail for {len(pairs)} completed games (pace calibration)..."
|
|
)
|
|
fetched = fetch_hltb_detail_missing(pairs)
|
|
return fetched > 0
|
|
|
|
|
|
def _refresh_recently_played_completions(
|
|
games: list[GameInfo],
|
|
config: Config,
|
|
) -> list[GameInfo]:
|
|
"""Refresh achievement data for incomplete games played since last scan.
|
|
|
|
Makes 1 ``GetOwnedGames`` request + 1 ``GetPlayerAchievements`` per
|
|
recently-played incomplete game. Finds games newly completed since the
|
|
last ``scan`` without re-scanning the whole library.
|
|
|
|
Returns a new list with updated GameInfo objects for any game that was
|
|
played after the snapshot was written; all other games are unchanged.
|
|
"""
|
|
try:
|
|
snapshot_mtime = SNAPSHOT_FILE.stat().st_mtime
|
|
except OSError:
|
|
return games
|
|
|
|
try:
|
|
client = SteamAPIClient(config.steam_api_key, config.steam_id)
|
|
owned_raw = client.get_owned_games()
|
|
except SteamAPIError:
|
|
logger.debug("Steam API unavailable; skipping completion refresh.")
|
|
return games
|
|
last_played_map = {g["appid"]: g.get("rtime_last_played", 0) for g in owned_raw}
|
|
|
|
to_refresh = [
|
|
g
|
|
for g in games
|
|
if not g.is_complete and last_played_map.get(g.app_id, 0) > snapshot_mtime
|
|
]
|
|
|
|
if not to_refresh:
|
|
return games
|
|
|
|
_echo(
|
|
f"Refreshing {len(to_refresh)} recently-played game(s)"
|
|
" for up-to-date completion status..."
|
|
)
|
|
|
|
game_map = {g.app_id: g for g in games}
|
|
|
|
def _refresh_one(game: GameInfo) -> GameInfo:
|
|
achievements = client.get_achievement_details(game.app_id)
|
|
if not achievements:
|
|
return game
|
|
unlocked = sum(1 for a in achievements if a.achieved)
|
|
return GameInfo(
|
|
app_id=game.app_id,
|
|
name=game.name,
|
|
total_achievements=len(achievements),
|
|
unlocked_achievements=unlocked,
|
|
playtime_minutes=game.playtime_minutes,
|
|
achievements=achievements,
|
|
completionist_hours=game.completionist_hours,
|
|
comp_100_count=game.comp_100_count,
|
|
count_comp=game.count_comp,
|
|
)
|
|
|
|
with ThreadPoolExecutor(max_workers=20) as pool:
|
|
futures = {pool.submit(_refresh_one, g): g for g in to_refresh}
|
|
for future in as_completed(futures):
|
|
refreshed = future.result()
|
|
game_map[refreshed.app_id] = refreshed
|
|
|
|
return list(game_map.values())
|