diff --git a/steam_backlog_enforcer/_hltb_detail.py b/steam_backlog_enforcer/_hltb_detail.py index 341ea0b..4e3ed45 100644 --- a/steam_backlog_enforcer/_hltb_detail.py +++ b/steam_backlog_enforcer/_hltb_detail.py @@ -56,8 +56,32 @@ def _as_positive_int(value: object) -> int: return 0 +def _platform_comp_high_candidates(game_data: dict[str, Any]) -> list[int]: + """Collect positive ``comp_high`` values from ``platformData`` entries.""" + platform_data = game_data.get("platformData", []) + if not isinstance(platform_data, list): + return [] + candidates = [] + for entry in platform_data: + if isinstance(entry, dict): + v = _as_positive_int(entry.get("comp_high", 0)) + if v > 0: + candidates.append(v) + return candidates + + def _extract_base_leisure_hours(game_data: dict[str, Any]) -> float: - """Extract base-game leisure hours from game detail data.""" + """Extract base-game leisure hours from game detail data. + + Returns the highest (slowest) time to beat across all play styles. + Candidates considered: + + 1. ``comp_high`` from each entry in ``platformData`` — the per-platform + slowest individual submission displayed on the HLTB page. + 2. The ``_h`` (leisure/high) fields from ``game[0]``: + ``comp_main_h``, ``comp_plus_h``, ``comp_100_h``, ``comp_all_h``. + 3. Falls back to average times: ``comp_main``, ``comp_plus``, ``comp_100``. + """ games = game_data.get("game", []) if not isinstance(games, list) or not games: return -1 @@ -65,9 +89,25 @@ def _extract_base_leisure_hours(game_data: dict[str, Any]) -> float: return -1 base = games[0] - leisure_s = _as_positive_int(base.get("comp_100_h", 0)) + candidates = _platform_comp_high_candidates(game_data) + + # 2. Leisure/high fields from the game record + for field in ("comp_main_h", "comp_plus_h", "comp_100_h", "comp_all_h"): + v = _as_positive_int(base.get(field, 0)) + if v > 0: + candidates.append(v) + + leisure_s = max(candidates) if candidates else 0 + + # 3. Fallback: average completion times if leisure_s <= 0: - leisure_s = _as_positive_int(base.get("comp_100", 0)) + avg_candidates = [ + _as_positive_int(base.get("comp_main", 0)), + _as_positive_int(base.get("comp_plus", 0)), + _as_positive_int(base.get("comp_100", 0)), + ] + leisure_s = max(avg_candidates) + if leisure_s <= 0: return -1 @@ -100,9 +140,9 @@ def _extract_dlc_relationships(game_data: dict[str, Any]) -> list[tuple[int, flo def _extract_leisure_hours(game_data: dict[str, Any]) -> float: """Compute total leisure hours: base game + all DLCs. - Uses ``comp_100_h`` (leisure completionist) from the game detail page. - Falls back to ``comp_100`` (average completionist) if leisure unavailable. - Also sums leisure time from any DLC listed in ``relationships``. + Uses the highest (slowest) time across ``platformData comp_high`` and + leisure ``_h`` fields from ``game[0]``. Falls back to average completion + times. Also sums leisure time from any DLC listed in ``relationships``. """ base_hours = _extract_base_leisure_hours(game_data) if base_hours <= 0: diff --git a/steam_backlog_enforcer/library_hider.py b/steam_backlog_enforcer/library_hider.py index f923716..e4f62d2 100644 --- a/steam_backlog_enforcer/library_hider.py +++ b/steam_backlog_enforcer/library_hider.py @@ -31,7 +31,7 @@ import websockets logger = logging.getLogger(__name__) _CDP_PORT = 8080 -_CDP_TIMEOUT = 30 +_CDP_TIMEOUT = 120 _STEAM_STARTUP_WAIT = 45 @@ -85,9 +85,18 @@ def _evaluate_js(expression: str) -> dict: def _cdp_result_value(result: dict) -> str: """Extract the return value from a CDP Runtime.evaluate response.""" - inner = result.get("result", {}).get("result", {}) - if "exceptionDetails" in result.get("result", {}): - desc = inner.get("description", "Unknown JS error") + outer = result.get("result", {}) + inner = outer.get("result", {}) + if "exceptionDetails" in outer: + exc_details = outer["exceptionDetails"] + exc = exc_details.get("exception", {}) + desc = ( + inner.get("description") + or exc.get("description") + or exc_details.get("text") + or repr(exc_details) + ) + logger.debug("CDP exception details: %s", exc_details) msg = f"JS evaluation error: {desc}" raise RuntimeError(msg) value: str = inner.get("value", "") @@ -251,6 +260,19 @@ def hide_other_games( const maxPasses = {_MAX_HIDE_PASSES}; const batchSize = {_HIDE_BATCH_SIZE}; + async function safeHide(ids) {{ + if (ids.length === 0) return 0; + try {{ + await collectionStore.SetAppsAsHidden(ids, true); + return ids.length; + }} catch(e) {{ + if (ids.length === 1) return 0; + const mid = Math.floor(ids.length / 2); + return (await safeHide(ids.slice(0, mid))) + + (await safeHide(ids.slice(mid))); + }} + }} + for (let pass = 0; pass < maxPasses; pass++) {{ let visible = coll && coll.visibleApps ? coll.visibleApps.map(a => a.appid).filter(id => id !== allowed) @@ -267,8 +289,7 @@ def hide_other_games( for (let i = 0; i < visible.length; i += batchSize) {{ const batch = visible.slice(i, i + batchSize); - await collectionStore.SetAppsAsHidden(batch, true); - totalHidden += batch.length; + totalHidden += await safeHide(batch); }} await new Promise(r => setTimeout(r, {_SETTLE_DELAY_MS})); diff --git a/steam_backlog_enforcer/tests/test_hltb_detail.py b/steam_backlog_enforcer/tests/test_hltb_detail.py index 8c6d274..c36af86 100644 --- a/steam_backlog_enforcer/tests/test_hltb_detail.py +++ b/steam_backlog_enforcer/tests/test_hltb_detail.py @@ -36,6 +36,70 @@ class TestInternalHelpers: data: dict[str, Any] = {"game": [123]} assert _extract_base_leisure_hours(data) == -1 + def test_extract_base_leisure_platform_data_comp_high_is_max(self) -> None: + data: dict[str, Any] = { + "game": [{"comp_100_h": 16063}], + "platformData": [{"platform": "PC", "comp_high": 23760}], + } + assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2) + + def test_extract_base_leisure_h_field_exceeds_platform_comp_high(self) -> None: + data: dict[str, Any] = { + "game": [{"comp_100_h": 25000}], + "platformData": [{"platform": "PC", "comp_high": 23760}], + } + assert _extract_base_leisure_hours(data) == round(25000 / 3600, 2) + + def test_extract_base_leisure_max_of_multiple_platforms(self) -> None: + data: dict[str, Any] = { + "game": [{}], + "platformData": [ + {"platform": "PC", "comp_high": 23760}, + {"platform": "Switch", "comp_high": 18000}, + ], + } + assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2) + + def test_extract_base_leisure_platform_data_not_list(self) -> None: + data: dict[str, Any] = { + "game": [{"comp_100_h": 16063}], + "platformData": "not_a_list", + } + assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2) + + def test_extract_base_leisure_platform_non_dict_entry_skipped(self) -> None: + data: dict[str, Any] = { + "game": [{"comp_100_h": 16063}], + "platformData": ["bad", {"platform": "PC", "comp_high": 23760}], + } + assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2) + + def test_extract_base_leisure_platform_comp_high_zero_skipped(self) -> None: + data: dict[str, Any] = { + "game": [{"comp_100_h": 16063}], + "platformData": [{"platform": "PC", "comp_high": 0}], + } + assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2) + + def test_extract_base_leisure_max_of_h_fields(self) -> None: + data: dict[str, Any] = { + "game": [ + { + "comp_main_h": 14951, + "comp_plus_h": 17957, + "comp_100_h": 16063, + "comp_all_h": 17959, + } + ], + } + assert _extract_base_leisure_hours(data) == round(17959 / 3600, 2) + + def test_extract_base_leisure_fallback_to_avg_comp_main(self) -> None: + data: dict[str, Any] = { + "game": [{"comp_main": 10800, "comp_plus": 0, "comp_100": 0}], + } + assert _extract_base_leisure_hours(data) == round(10800 / 3600, 2) + def test_extract_dlc_relationships_skips_non_dict(self) -> None: data: dict[str, Any] = { "relationships": [ @@ -376,3 +440,29 @@ class TestFetchLeisureTimes: expected = round((21243 + 4075) / 3600, 2) assert cache[1289310] == expected assert results[0].completionist_hours == expected + + def test_with_explicit_count_comp(self) -> None: + """Pass a non-None count_comp to cover the False branch of the None check.""" + results = [ + HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ), + ] + game_data: dict[str, Any] = { + "game": [{"comp_100_h": 3600}], + "relationships": [], + } + cache: dict[int, float] = {} + with patch( + "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", + new_callable=AsyncMock, + return_value=game_data, + ): + asyncio.run( + _fetch_leisure_times(results, cache, {}, None, count_comp={440: 5}) + ) + assert cache[440] == 1.0