195
homelab/scripts/steam_library_size.py
Normal file
195
homelab/scripts/steam_library_size.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user