Files
homelab-docs/homelab/scripts/check_missing_steam_games.py
Andrey 3c00fbf67b Update storage docs and sync homelab scripts
Align homelab docs repo with local homelab changes, including updated storage layout and the Steam games presence checker script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 00:00:47 +03:00

213 lines
7.3 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
import json
import os
import sys
import unicodedata
from typing import Dict, List, Tuple, Any
DEFAULT_GAMES_JSON = os.path.join(
os.path.dirname(__file__), "steam_library_with_sizes.json"
)
DEFAULT_GAMES_ROOT = "/mnt/nextcloud-hdd/games"
IGNORE_DIRS = {".", "..", "common", "lost+found"}
def normalize(text: str) -> str:
"""Normalize game / directory names for comparison."""
text = unicodedata.normalize("NFKD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
text = text.lower()
return "".join(ch for ch in text if ch.isalnum())
def load_games(json_path: str) -> List[Dict[str, Any]]:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
raise ValueError(f"Unexpected JSON structure in {json_path}, expected list")
return data
def list_game_dirs(root: str) -> List[str]:
entries: List[str] = []
for name in os.listdir(root):
if name in IGNORE_DIRS or name.startswith("."):
continue
full = os.path.join(root, name)
if os.path.isdir(full):
entries.append(name)
return sorted(entries)
def build_dir_index(dirs: List[str]) -> Dict[str, List[str]]:
index: Dict[str, List[str]] = {}
for d in dirs:
nd = normalize(d)
index.setdefault(nd, []).append(d)
return index
def similar_candidates(
norm_name: str, dirs: List[str]
) -> Tuple[float, List[str]]:
from difflib import SequenceMatcher
best_ratio = 0.0
best: List[str] = []
for d in dirs:
nd = normalize(d)
ratio = SequenceMatcher(None, norm_name, nd).ratio()
if ratio > best_ratio:
best_ratio = ratio
best = [d]
elif ratio == best_ratio and ratio > 0:
best.append(d)
return best_ratio, best
def classify_games(
games: List[Dict[str, Any]], server_dirs: List[str]
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, int]]:
norm_dir_map = build_dir_index(server_dirs)
missing: List[Dict[str, Any]] = []
doubtful: List[Dict[str, Any]] = []
for g in games:
appid = str(g.get("appid", ""))
name = g.get("name", "")
norm_name = normalize(name)
candidates = norm_dir_map.get(norm_name, [])
if len(candidates) == 1:
# Confident уникальное соответствие: игра считается присутствующей
continue
record = {
"appid": appid,
"name": name,
"attempted_match": False,
"reason": "",
}
if len(candidates) > 1:
record["attempted_match"] = True
record[
"reason"
] = f"Найдено несколько директорий с одинаковым нормализованным именем: {candidates}"
missing.append(record)
doubtful.append(
{
"appid": appid,
"name": name,
"candidates": candidates,
"confidence": "низкая уверенность (несколько совпадений по нормализованному имени)",
}
)
continue
# Нет точного совпадения по нормализованному имени: ищем похожие директории,
# но не считаем их найденными.
best_ratio, best_matches = similar_candidates(norm_name, server_dirs)
if best_ratio >= 0.85:
record["attempted_match"] = True
record["reason"] = (
"Найден(ы) похожие директории, но совпадение не однозначное "
f"(коэффициент схожести {best_ratio:.2f}, кандидаты: {best_matches})"
)
if best_ratio >= 0.92:
conf = "вероятно совпадение"
else:
conf = "низкая уверенность"
doubtful.append(
{
"appid": appid,
"name": name,
"candidates": best_matches,
"confidence": f"{conf} (similarity={best_ratio:.2f})",
}
)
else:
record[
"reason"
] = "Похожих директорий не найдено (по нормализованному имени)"
missing.append(record)
stats = {
"total_games_in_json": len(games),
"total_server_dirs": len(server_dirs),
"missing_count": len(missing),
"doubtful_count": len(doubtful),
}
return missing, doubtful, stats
def print_markdown(
missing: List[Dict[str, Any]], doubtful: List[Dict[str, Any]], stats: Dict[str, int]
) -> None:
print("### Статистика")
print()
print(f"- **Всего игр в JSON**: {stats['total_games_in_json']}")
print(f"- **Всего директорий на сервере**: {stats['total_server_dirs']}")
print(f"- **Отсутствующие игры**: {stats['missing_count']}")
print(f"- **Сомнительные сопоставления**: {stats['doubtful_count']}")
print()
print("### Игры, отсутствующие на сервере")
print()
if not missing:
print("Все игры из JSON найдены на сервере по строгому сопоставлению.")
else:
print("| appid | name | попытка сопоставления | комментарий |")
print("| --- | --- | --- | --- |")
for r in missing:
attempted = "да" if r["attempted_match"] else "нет"
reason = r.get("reason", "").replace("\n", " ")
print(f"| {r['appid']} | {r['name']} | {attempted} | {reason} |")
print()
print("### Сомнительные/неоднозначные сопоставления")
print()
if not doubtful:
print("Сомнительных сопоставлений не обнаружено.")
return
print("| appid | name | директории-кандидаты | степень уверенности |")
print("| --- | --- | --- | --- |")
for d in doubtful:
candidates = ", ".join(d.get("candidates") or [])
conf = d.get("confidence", "")
print(f"| {d['appid']} | {d['name']} | {candidates} | {conf} |")
def main(argv: List[str]) -> int:
games_json = DEFAULT_GAMES_JSON
games_root = DEFAULT_GAMES_ROOT
if len(argv) > 1:
games_json = argv[1]
if len(argv) > 2:
games_root = argv[2]
if not os.path.isfile(games_json):
print(f"ERROR: JSON file not found: {games_json}", file=sys.stderr)
return 1
if not os.path.isdir(games_root):
print(f"ERROR: Games root not found: {games_root}", file=sys.stderr)
return 1
games = load_games(games_json)
server_dirs = list_game_dirs(games_root)
missing, doubtful, stats = classify_games(games, server_dirs)
print_markdown(missing, doubtful, stats)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))