Skip to content

Instantly share code, notes, and snippets.

@thegamecracks
Created October 4, 2025 15:36
Show Gist options
  • Select an option

  • Save thegamecracks/d35f94ecc8f4a0ad3eb9d42817698c70 to your computer and use it in GitHub Desktop.

Select an option

Save thegamecracks/d35f94ecc8f4a0ad3eb9d42817698c70 to your computer and use it in GitHub Desktop.
Produce filesize statistics from a list of workshop mods
#!/usr/bin/python3
"""Produce filesize statistics from a list of workshop mods."""
# /// script
# dependencies = []
# requires-python = ">=3.11"
# ///
from __future__ import annotations
import argparse
import datetime
import json
import math
import re
import urllib.parse
import urllib.request
from contextlib import contextmanager
from dataclasses import dataclass, field
from http.client import HTTPResponse
from pathlib import Path
from typing import Any, Collection, Iterable, Iterator, Self
# https://steamapi.xpaw.me/#ISteamRemoteStorage/GetPublishedFileDetails
STEAMAPI_FILEDETAILS_URL = (
"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
)
# https://steamapi.xpaw.me/#ISteamRemoteStorage/GetCollectionDetails
STEAMAPI_COLLECTION_URL = (
"https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"
)
WORKSHOP_ITEM_PATTERN = re.compile(
r"https://steamcommunity.com/sharedfiles/filedetails/\?id=(\d+)"
)
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"-s",
"--sort",
choices=("largest", "smallest", "title"),
default="largest",
help="How mods are sorted in the table",
)
parser.add_argument(
"mods",
help="Workshop item IDs, collections, or modpack files to sum",
nargs="+",
)
args = parser.parse_args()
mods: list[str] = args.mods
item_ids = parse_mods_to_item_ids(mods)
if not item_ids:
return print("Nothing to sum")
if args.sort == "largest":
key = lambda item: (-item.size, item.title)
elif args.sort == "smallest":
key = lambda item: (item.size, item.title)
else:
key = lambda item: item.title
items = get_published_file_details(list(item_ids))
items = expand_collection_file_details(items)
items = {item.id: item for item in items}
items = sorted(items.values(), key=key)
print("Number of mods:", len(items))
if args.sort in ("largest", "smallest"):
sizes = [item.size for item in items]
total_sizes_down = cumulative_sum(sizes)
total_sizes_up = cumulative_sum(reversed(sizes))
print(f" # Total (up) Total (down) Size Title")
for i, (item, up, down) in enumerate(
zip(items, reversed(total_sizes_up), total_sizes_down)
):
size = natural_bytes(item.size)
up = natural_bytes(up)
down = natural_bytes(down)
print(f"{i+1:>3d} {up:>10} {down:>12} {size:>9} {item.title}")
else:
print(f" # Size Title")
for i, item in enumerate(items):
size = natural_bytes(item.size)
print(f"{i+1:>3d} {size:>9} {item.title}")
def parse_mods_to_item_ids(mods: Iterable[str]) -> set[int]:
item_ids: set[int] = set()
unknown: list[str] = []
for m in mods:
try:
item_ids.add(int(m))
except ValueError:
unknown.append(m)
for m in unknown:
path = Path(m)
if not path.exists():
raise ValueError(f"Unknown mod: {m}")
item_ids |= read_modpack_item_ids(path)
return item_ids
def read_modpack_item_ids(modpack: Path) -> set[int]:
content = modpack.read_text("utf-8")
return {int(m[1]) for m in WORKSHOP_ITEM_PATTERN.finditer(content)}
def cumulative_sum(nums: Iterable[int]) -> list[int]:
sums: list[int] = []
total = 0
for n in nums:
total += n
sums.append(total)
return sums
@dataclass
class CollectionDetails:
id: int
children: list[int]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
if data["result"] != 1:
raise ValueError(f"Unexpected result code: {data['result']}")
return cls(
id=int(data["publishedfileid"]),
children=[int(child["publishedfileid"]) for child in data["children"]],
)
@dataclass
class FileDetails:
id: int
title: str
description: str = field(repr=False)
created_at: datetime.datetime
updated_at: datetime.datetime
size: int
tags: list[Tag]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
if data["result"] != 1:
raise ValueError(f"Unexpected result code: {data['result']}")
created_at = datetime.datetime.fromtimestamp(data["time_created"]).astimezone()
updated_at = datetime.datetime.fromtimestamp(data["time_updated"]).astimezone()
return cls(
id=int(data["publishedfileid"]),
title=data["title"],
description=data["description"],
created_at=created_at,
updated_at=updated_at,
size=int(data["file_size"]),
tags=[Tag.from_dict(tag) for tag in data["tags"]],
)
@dataclass
class Tag:
name: str
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
return cls(name=data["tag"])
def expand_collection_file_details(
items: Iterable[FileDetails],
) -> list[FileDetails]:
collections: list[FileDetails] = []
ret: list[FileDetails] = []
for item in items:
if any(tag.name == "Collection" for tag in item.tags):
collections.append(item)
else:
ret.append(item)
if collections:
expanded = get_collection_details([item.id for item in collections])
expanded = set(id for item in expanded for id in item.children)
expanded -= {item.id for item in ret}
expanded = get_published_file_details(expanded)
ret.extend(expanded)
return ret
def get_collection_details(item_ids: Collection[int]) -> list[CollectionDetails]:
data: dict[str, object] = {"collectioncount": len(item_ids)}
for i, item in enumerate(item_ids):
data[f"publishedfileids[{i}]"] = str(item)
with http_post(STEAMAPI_COLLECTION_URL, data=data) as response:
raise_for_status(response)
payload = response.read()
payload = json.loads(payload)
details = payload["response"]["collectiondetails"]
items = [CollectionDetails.from_dict(item) for item in details]
if len(items) != len(item_ids):
raise ValueError(f"Expected {len(item_ids)} items, but received {len(items)}")
return items
def get_published_file_details(item_ids: Collection[int]) -> list[FileDetails]:
data: dict[str, object] = {"itemcount": len(item_ids)}
for i, item in enumerate(item_ids):
data[f"publishedfileids[{i}]"] = str(item)
with http_post(STEAMAPI_FILEDETAILS_URL, data=data) as response:
raise_for_status(response)
payload = response.read()
payload = json.loads(payload)
details = payload["response"]["publishedfiledetails"]
items = [FileDetails.from_dict(item) for item in details]
if len(items) != len(item_ids):
raise ValueError(f"Expected {len(item_ids)} items, but received {len(items)}")
return items
@contextmanager
def http_post(url: str, data: dict[str, object]) -> Iterator[HTTPResponse]:
request = urllib.request.Request(
url,
data=urllib.parse.urlencode(data).encode(),
headers={},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
assert isinstance(response, HTTPResponse)
yield response
def natural_bytes(n: int) -> str:
if n < 1e3:
return f"{n}B"
elif n < 1e6:
return f"{math.ceil(n / 1e3)}KB"
return f"{math.ceil(n / 1e6):,d}MB"
def raise_for_status(response: HTTPResponse):
if not 200 <= response.status < 300:
raise RuntimeError(f"Unexpected HTTP status code: {response.status}")
if __name__ == "__main__":
main()
@thegamecracks
Copy link
Author

Example output:

$ ./sum_modpack.py 3489945148
Number of mods: 14
  #  Total (up)  Total (down)       Size  Title
  1     1,233MB         948MB      948MB  JSRS SOUNDMOD 2025 Beta - RC3
  2       286MB       1,049MB      102MB  Project SFX: Remastered
  3       185MB       1,138MB       90MB  New CSAT (Overhaul) DVK
  4        95MB       1,174MB       36MB  Death and Hit reactions
  5        60MB       1,205MB       31MB  Project SFX: Footsteps
  6        29MB       1,221MB       17MB  Advanced Vault System: Remastered
  7        13MB       1,225MB        5MB  CBA_A3
  8         8MB       1,228MB        3MB  Hide Among The Grass - HATG
  9         6MB       1,230MB        3MB  JSRS SOUNDMOD 2025 Beta - AiO Compat Files RC3
 10         4MB       1,232MB        2MB  Alternative Running
 11         2MB       1,233MB        2MB  LAMBS_Danger.fsm
 12       192KB       1,233MB      130KB  WBK Simple Blood
 13        63KB       1,233MB       60KB  Drongos Grenade Tweaks
 14         3KB       1,233MB        3KB  Splendid Smoke

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment