from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry COLLECTION_DETAILS_URL = ( "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/" ) PUBLISHED_FILE_DETAILS_URL = ( "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/" ) @dataclass(frozen=True) class WorkshopDetails: workshop_id: str title: str time_updated: int file_size: int consumer_app_id: int result: int def _chunks(values: list[str], size: int) -> Iterable[list[str]]: for index in range(0, len(values), size): yield values[index : index + size] class SteamWorkshopClient: def __init__(self, timeout: int = 30): self.timeout = timeout self.session = requests.Session() retries = Retry( total=4, backoff_factor=0.75, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=frozenset({"POST"}), ) self.session.mount("https://", HTTPAdapter(max_retries=retries)) self.session.headers.update({"User-Agent": "ProgressionMods/1.0"}) def get_collections( self, collection_ids: list[str] ) -> tuple[dict[str, list[str]], set[str]]: payload: dict[str, str | int] = {"collectioncount": len(collection_ids)} for index, collection_id in enumerate(collection_ids): payload[f"publishedfileids[{index}]"] = collection_id response = self.session.post( COLLECTION_DETAILS_URL, data=payload, timeout=self.timeout ) response.raise_for_status() details = response.json().get("response", {}).get("collectiondetails", []) result: dict[str, list[str]] = {} collection_references: set[str] = set() for detail in details: collection_id = str(detail.get("publishedfileid", "")) if int(detail.get("result", 0)) != 1: raise RuntimeError(f"Steam rejected collection {collection_id}") children = sorted( detail.get("children", []), key=lambda child: int(child.get("sortorder", 0)), ) child_ids = [str(child["publishedfileid"]) for child in children] result[collection_id] = child_ids collection_references.update( str(child["publishedfileid"]) for child in children if int(child.get("filetype", 0) or 0) == 2 ) # Steam currently reports filetype=2 for nested collections. Also # recognize configured collections by id in case that field is absent. all_children = { workshop_id for workshop_ids in result.values() for workshop_id in workshop_ids } collection_references.update(set(collection_ids) & all_children) return result, collection_references def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]: result: dict[str, WorkshopDetails] = {} for batch in _chunks(workshop_ids, 100): payload: dict[str, str | int] = {"itemcount": len(batch)} for index, workshop_id in enumerate(batch): payload[f"publishedfileids[{index}]"] = workshop_id response = self.session.post( PUBLISHED_FILE_DETAILS_URL, data=payload, timeout=self.timeout ) response.raise_for_status() details = ( response.json().get("response", {}).get("publishedfiledetails", []) ) for detail in details: workshop_id = str(detail.get("publishedfileid", "")) result[workshop_id] = WorkshopDetails( workshop_id=workshop_id, title=str(detail.get("title", "")), time_updated=int(detail.get("time_updated", 0) or 0), file_size=int(detail.get("file_size", 0) or 0), consumer_app_id=int(detail.get("consumer_app_id", 0) or 0), result=int(detail.get("result", 0) or 0), ) return result