Files
homelab-docs/homelab/scripts/steam_library_size.py
2026-02-23 16:47:17 +03:00

196 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Расчёт суммарного размера библиотеки Steam по данным из steam_library.json.
Приоритет: api.steamcmd.net (реальные размеры депо). Запас: системные требования
в магазине Steam (часто занижены). Результаты кэшируются в steam_app_sizes.json.
"""
import json
import re
import time
import urllib.request
from pathlib import Path
from typing import Optional
LIBRARY_FILE = "steam_library.json"
SIZES_CACHE_FILE = "steam_app_sizes.json"
OUTPUT_FILE = "steam_library_with_sizes.json"
REQUEST_DELAY = 0.4
def get_app_size_from_steamcmd(appid: int) -> Optional[float]:
"""
Размер в ГБ из api.steamcmd.net (депо, реальный объём установки).
Учитываются только депо для Windows, для языков — только english, чтобы не суммировать все локали.
"""
url = f"https://api.steamcmd.net/v1/info/{appid}"
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode())
except Exception:
return None
if data.get("status") != "success" or "data" not in data:
return None
app_data = data["data"].get(str(appid))
if not app_data or "depots" not in app_data:
return None
depots = app_data["depots"]
total_bytes = 0
for depot_id, depot in depots.items():
if depot_id in ("appmanagesdlc", "baselanguages", "branches", "privatebranches"):
continue
if not isinstance(depot, dict):
continue
config = depot.get("config") or {}
oslist = config.get("oslist", "")
if "windows" not in oslist.lower():
continue
manifests = depot.get("manifests")
if not manifests or "public" not in manifests:
continue
size_str = manifests["public"].get("size")
if not size_str:
continue
try:
size_bytes = int(size_str)
except (TypeError, ValueError):
continue
language = config.get("language", "")
if language and language != "english":
continue
total_bytes += size_bytes
if total_bytes == 0:
return None
return total_bytes / (1024 ** 3)
def parse_size_from_text(text: str) -> Optional[float]:
"""Извлекает размер в ГБ из строки системных требований."""
if not text:
return None
text = text.replace(",", ".")
m = re.search(r"(\d+(?:\.\d+)?)\s*GB", text, re.IGNORECASE)
if m:
return float(m.group(1))
m = re.search(r"(\d+(?:\.\d+)?)\s*MB", text, re.IGNORECASE)
if m:
return float(m.group(1)) / 1024
return None
def get_app_size_from_store(appid: int) -> Optional[float]:
"""Размер в ГБ из системных требований store.steampowered.com (часто занижен)."""
url = f"https://store.steampowered.com/api/appdetails?appids={appid}&l=english"
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
except Exception:
return None
block = data.get(str(appid))
if not block or not block.get("success") or "data" not in block:
return None
reqs = block["data"].get("pc_requirements")
if not reqs:
return None
if isinstance(reqs, dict):
for part in (reqs.get("minimum"), reqs.get("recommended")):
size = parse_size_from_text(part)
if size is not None:
return size
elif isinstance(reqs, str):
return parse_size_from_text(reqs)
return None
def get_app_size(appid: int) -> Optional[float]:
"""Сначала steamcmd (реальный размер), при отсутствии — магазин."""
size = get_app_size_from_steamcmd(appid)
if size is not None:
return size
return get_app_size_from_store(appid)
def load_library(path: Path) -> list[dict]:
with open(path, encoding="utf-8") as f:
return json.load(f)
def load_sizes_cache(path: Path) -> dict:
if not path.exists():
return {}
with open(path, encoding="utf-8") as f:
return {int(k): v for k, v in json.load(f).items()}
def save_sizes_cache(path: Path, cache: dict) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=2)
def progress_bar(current: int, total: int, width: int = 40) -> str:
"""Строка прогресс-бара: [=======> ] 45/289 15%"""
if total <= 0:
return ""
pct = current / total
filled = int(width * pct)
bar = "=" * filled + ">" * (1 if filled < width else 0) + " " * (width - filled - 1)
return f"[{bar}] {current}/{total} {pct*100:.0f}%"
def main() -> None:
base = Path(__file__).parent
lib_path = base / LIBRARY_FILE
cache_path = base / SIZES_CACHE_FILE
out_path = base / OUTPUT_FILE
if not lib_path.exists():
print(f"Сначала запусти steam-library-to-json.py — нужен файл {LIBRARY_FILE}")
return
library = load_library(lib_path)
cache = load_sizes_cache(cache_path)
total_gb = 0.0
unknown_count = 0
results = []
total_games = len(library)
for i, game in enumerate(library):
appid = game["appid"]
name = game.get("name", "")
if appid in cache:
size_gb = cache[appid]
else:
size_gb = get_app_size(appid)
cache[appid] = size_gb
time.sleep(REQUEST_DELAY)
if size_gb is not None:
total_gb += size_gb
else:
unknown_count += 1
results.append({
"appid": appid,
"name": name,
"size_gb": round(size_gb, 2) if size_gb is not None else None,
})
print(f"\r{progress_bar(i + 1, total_games)} {name[:35]:<35}", end="", flush=True)
print()
save_sizes_cache(cache_path, cache)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print()
print(f"Игр в библиотеке: {len(library)}")
print(f"С известным размером: {len(library) - unknown_count}")
print(f"Без размера: {unknown_count}")
print(f"Суммарный размер: {total_gb:.1f} ГБ")
print(f"Результат: {out_path}")
print(f"Кэш: {cache_path}")
if __name__ == "__main__":
main()