feat(workshop): batched expand_collections() helper

Adds expand_collections(ids) to steam_workshop: one GetCollectionDetails
POST covers a mixed batch of item and collection IDs, returning a flat
deduplicated list of item IDs in input order. Foundation for the upcoming
items-vs-collection autodetect in the workshop add_items handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-19 00:23:54 +02:00
parent f1b0cbb5f1
commit 6a04594c19
No known key found for this signature in database
2 changed files with 177 additions and 0 deletions

View file

@ -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]:

View file

@ -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"])