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>
213 lines
7.3 KiB
Python
213 lines
7.3 KiB
Python
#!/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))
|
||
|