diff --git a/l4d2web/l4d2web/services/steam_workshop.py b/l4d2web/l4d2web/services/steam_workshop.py index bebd7f4..549b710 100644 --- a/l4d2web/l4d2web/services/steam_workshop.py +++ b/l4d2web/l4d2web/services/steam_workshop.py @@ -140,6 +140,64 @@ def resolve_collection(collection_id: str) -> list[str]: return children +def expand_collections(ids: list[str]) -> list[str]: + """Resolve a mix of item and collection IDs into a flat list of item IDs. + + Performs one batched POST to GetCollectionDetails. For each input ID: + - If Steam returns result==1 with a children array, the ID is a + collection — replace it with its non-nested child item IDs in order. + - If Steam returns result==9 (k_EResultFileNotFound), the ID is not a + collection — pass it through unchanged. + + Result preserves input order; collection children are inserted at the + position the collection ID held. Duplicates (across pass-throughs and + expanded children) are removed, keeping first occurrence. + """ + if not ids: + return [] + for sid in ids: + if not _NUMERIC_ID_RE.fullmatch(sid): + raise ValueError(f"steam id must be digits only: {sid!r}") + + payload: dict[str, str | int] = {"collectioncount": len(ids)} + for index, sid in enumerate(ids): + payload[f"publishedfileids[{index}]"] = sid + + response = _session().post( + GET_COLLECTION_DETAILS_URL, + data=payload, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + body = response.json() + + by_id: dict[str, dict] = { + str(entry.get("publishedfileid", "")): entry + for entry in body.get("response", {}).get("collectiondetails", []) + } + + expanded: list[str] = [] + seen: set[str] = set() + + def _add(sid: str) -> None: + if sid and sid not in seen: + seen.add(sid) + expanded.append(sid) + + for sid in ids: + entry = by_id.get(sid) + if entry and entry.get("result") == 1 and "children" in entry: + for child in entry["children"]: + if child.get("filetype", 0) != 0: + continue + child_id = child.get("publishedfileid") + if child_id is not None: + _add(str(child_id)) + else: + _add(sid) + return expanded + + def fetch_metadata_batch( steam_ids: list[str], *, mode: Literal["add", "refresh"] ) -> list[WorkshopMetadata]: diff --git a/l4d2web/tests/test_steam_workshop.py b/l4d2web/tests/test_steam_workshop.py index 69b7001..3656570 100644 --- a/l4d2web/tests/test_steam_workshop.py +++ b/l4d2web/tests/test_steam_workshop.py @@ -310,3 +310,122 @@ def test_refresh_all_uses_thread_pool_and_collects_errors(tmp_path: Path) -> Non assert report.downloaded == 2 assert report.errors == 1 assert "2" in report.per_item_errors + + +def test_expand_collections_passes_through_items() -> None: + fake_response = MagicMock(status_code=200) + fake_response.raise_for_status = MagicMock() + fake_response.json.return_value = { + "response": { + "collectiondetails": [ + {"publishedfileid": "1001", "result": 9}, + {"publishedfileid": "1002", "result": 9}, + ] + } + } + with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): + result = steam_workshop.expand_collections(["1001", "1002"]) + assert result == ["1001", "1002"] + + +def test_expand_collections_replaces_collection_with_children() -> None: + fake_response = MagicMock(status_code=200) + fake_response.raise_for_status = MagicMock() + fake_response.json.return_value = { + "response": { + "collectiondetails": [ + { + "publishedfileid": "555", + "result": 1, + "children": [ + {"publishedfileid": "1001", "filetype": 0}, + {"publishedfileid": "1002", "filetype": 0}, + ], + }, + ] + } + } + with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): + result = steam_workshop.expand_collections(["555"]) + assert result == ["1001", "1002"] + + +def test_expand_collections_mixed_items_and_collections() -> None: + fake_response = MagicMock(status_code=200) + fake_response.raise_for_status = MagicMock() + fake_response.json.return_value = { + "response": { + "collectiondetails": [ + {"publishedfileid": "1001", "result": 9}, + { + "publishedfileid": "555", + "result": 1, + "children": [ + {"publishedfileid": "2001", "filetype": 0}, + {"publishedfileid": "2002", "filetype": 0}, + ], + }, + {"publishedfileid": "1003", "result": 9}, + ] + } + } + with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): + result = steam_workshop.expand_collections(["1001", "555", "1003"]) + # Input order preserved; collection's children inserted where the collection was. + assert result == ["1001", "2001", "2002", "1003"] + + +def test_expand_collections_skips_nested_collections() -> None: + fake_response = MagicMock(status_code=200) + fake_response.raise_for_status = MagicMock() + fake_response.json.return_value = { + "response": { + "collectiondetails": [ + { + "publishedfileid": "555", + "result": 1, + "children": [ + {"publishedfileid": "1001", "filetype": 0}, + {"publishedfileid": "9999", "filetype": 1}, + {"publishedfileid": "1002", "filetype": 0}, + ], + }, + ] + } + } + with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): + result = steam_workshop.expand_collections(["555"]) + assert result == ["1001", "1002"] + + +def test_expand_collections_deduplicates() -> None: + fake_response = MagicMock(status_code=200) + fake_response.raise_for_status = MagicMock() + fake_response.json.return_value = { + "response": { + "collectiondetails": [ + {"publishedfileid": "1001", "result": 9}, + { + "publishedfileid": "555", + "result": 1, + "children": [ + {"publishedfileid": "1001", "filetype": 0}, + {"publishedfileid": "1002", "filetype": 0}, + ], + }, + ] + } + } + with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): + result = steam_workshop.expand_collections(["1001", "555"]) + assert result == ["1001", "1002"] + + +def test_expand_collections_empty_input_returns_empty() -> None: + result = steam_workshop.expand_collections([]) + assert result == [] + + +def test_expand_collections_rejects_non_numeric_ids() -> None: + with pytest.raises(ValueError): + steam_workshop.expand_collections(["1001", "not-a-number"])