Initial homelab docs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-23 16:47:17 +03:00
commit ce731c28da
53 changed files with 6943 additions and 0 deletions

View 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()