diff --git a/get_data.py b/get_data.py
index a315369..4815a08 100644
--- a/get_data.py
+++ b/get_data.py
@@ -1,5 +1,5 @@
from fastapi import FastAPI, HTTPException, Request
-from fastapi.responses import Response, HTMLResponse, StreamingResponse
+from fastapi.responses import Response, HTMLResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
import requests, uvicorn, json
@@ -202,6 +202,8 @@ URLS = {
"live-status": "{host}api/abc/games/live-status?id={game_id}",
"box-score": "{host}api/abc/games/box-score?id={game_id}",
"play-by-play": "{host}api/abc/games/play-by-play?id={game_id}",
+ "players-stats-league": "{host}/api/abc/comps/players-stats?tag={league}&lang={lang}&maxResultCount=10000",
+ "players-stats-season": "{host}/api/abc/comps/players-stats?tag={league}&season={season}&lang={lang}&maxResultCount=10000",
}
@@ -1398,6 +1400,37 @@ async def lifespan(app: FastAPI):
)
thread_excel.start()
+ try:
+ season_stats = requests.get(
+ URLS["players-stats-season"].format(
+ host=HOST, league=LEAGUE, season=season, lang=LANG
+ )
+ ).json()
+ except Exception as ex:
+ season_stats = None
+ try:
+ league_stats = requests.get(
+ URLS["players-stats-league"].format(host=HOST, league=LEAGUE, lang=LANG)
+ ).json()
+ except Exception as ex:
+ league_stats = None
+
+ # 👇 ДОБАВЬ ЭТО
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
+
+ if season_stats is not None:
+ latest_data["season_stats"] = {
+ "ts": ts,
+ "data": season_stats,
+ }
+
+ if league_stats is not None:
+ latest_data["league_stats"] = {
+ "ts": ts,
+ "data": league_stats,
+ }
+ # 👆 ДО СЮДА
+
# 4. запускаем "длинные" потоки (они у тебя и так всегда)
thread_result_consumer = threading.Thread(
target=results_consumer,
@@ -1473,16 +1506,6 @@ app = FastAPI(
redoc_url=None, # ❌ отключает /redoc
openapi_url=None, # ❌ отключает /openapi.json
)
-# раздаём /shotmaps как статику из SHOTMAP_DIR
-# app.mount("/shotmaps", StaticFiles(directory=SHOTMAP_DIR), name="shotmaps")
-
-# @app.get("/shotmaps/{filename}")
-# async def get_shotmap(filename: str):
-# data = SHOTMAP_CACHE.get(filename)
-# if not data:
-# # если вдруг перезапустился процесс или такой карты нет
-# raise HTTPException(status_code=404, detail="Shotmap not found")
-# return Response(content=data, media_type="image/png")
def format_time(seconds: float | int) -> str:
@@ -2246,7 +2269,12 @@ async def team(who: str):
f"{item.get('displayNumber')}.png",
)
if item.get("startRole") == "Player"
- else ""
+ else os.path.join(
+ "D:\\Photos",
+ result["league"]["abcName"],
+ result[who]["name"],
+ f'Head Coach_{(item.get("lastName") or "").strip()} {(item.get("firstName") or "").strip()}.png',
+ )
),
# live-стата
"pts": _as_int(stats.get("points")),
@@ -3741,6 +3769,30 @@ async def last_5_games():
if not CALENDAR or "items" not in CALENDAR:
return {"opponent": "", "date": "", "place": "", "place_ru": ""}
+ WEEKDAYS_EN = [
+ "monday",
+ "tuesday",
+ "wednesday",
+ "thursday",
+ "friday",
+ "saturday",
+ "sunday",
+ ]
+ MONTHS_EN = [
+ "january",
+ "february",
+ "march",
+ "april",
+ "may",
+ "june",
+ "july",
+ "august",
+ "september",
+ "october",
+ "november",
+ "december",
+ ]
+
now = datetime.now() # наивное "сейчас"
best = None # {"dt": ..., "opp": ..., "place": "home"/"away"}
@@ -3782,11 +3834,23 @@ async def last_5_games():
place_ru = "дома" if best["place"] == "home" else "в гостях"
+ # 🆕 формируем английскую строку
+ dt = best["dt"]
+ weekday_en = WEEKDAYS_EN[dt.weekday()] # monday..sunday
+ month_en = MONTHS_EN[dt.month - 1] # january..december
+ day = dt.day # 1..31
+ place_en = "home" if best["place"] == "home" else "away"
+
+ formatted = (
+ f"{weekday_en}, {month_en} {day}, at {place_en} against {best['opp']}"
+ )
+
return {
"opponent": best["opp"],
"date": best["dt"].strftime("%Y-%m-%d %H:%M"),
"place": best["place"], # "home" / "away"
"place_ru": place_ru, # "дома" / "в гостях"
+ "formatted": formatted, # 🆕 "wednesday, march 26, at home against astana"
}
# последние 5 игр и результаты
@@ -3809,6 +3873,7 @@ async def last_5_games():
"nextGameDate": next1["date"],
"nextGamePlace": next1["place_ru"], # "дома" / "в гостях"
"nextGameHomeAway": next1["place"], # "home" / "away" (если нужно в логике)
+ "nextGameFormatted": next1["formatted"],
},
{
"teamName": team2_name,
@@ -3818,6 +3883,7 @@ async def last_5_games():
"nextGameDate": next2["date"],
"nextGamePlace": next2["place_ru"],
"nextGameHomeAway": next2["place"],
+ "nextGameFormatted": next2["formatted"],
},
]
return data
@@ -4906,6 +4972,1288 @@ async def dashboard():
return HTMLResponse(content=html)
+@app.get("/game_history")
+async def game_history():
+ pregame = get_latest_game_safe("pregame")
+ if not pregame:
+ return [{"Данных об истории команд нет!"}]
+
+ pregame_data = pregame["data"] if "data" in pregame else pregame
+ result = pregame_data.get("result", {}).get("gameHistory", {}) or {}
+ history = []
+
+ for row in result:
+ row_team1 = get_excel_row_for_team(row["team1"]["name"]) or {}
+ row_team2 = get_excel_row_for_team(row["team2"]["name"]) or {}
+ history.append(
+ {
+ "team1": row_team1.get("TeamTLA", ""),
+ "team2": row_team2.get("TeamTLA", ""),
+ "team1_logo": row_team1.get("TeamLogo", ""),
+ "team2_logo": row_team2.get("TeamLogo", ""),
+ "score1": row["game"]["score1"],
+ "score2": row["game"]["score2"],
+ "localDate": row["game"]["localDate"],
+ "team1Win": pregame_data.get("result", {}).get("team1Win"),
+ "team2Win": pregame_data.get("result", {}).get("team2Win"),
+ }
+ )
+ return history
+
+
+# Глобальное состояние выбранной сортировки
+CURRENT_SORT = {
+ "sort_by": None,
+ "direction": "desc",
+}
+
+
+def get_nested_value(data, path: str, default=0):
+ if not path:
+ return default
+
+ keys = path.split(".")
+ current = data
+
+ for key in keys:
+ if isinstance(current, dict):
+ if key not in current:
+ return default
+ current = current[key]
+ elif isinstance(current, list):
+ try:
+ idx = int(key)
+ if idx < 0 or idx >= len(current):
+ return default
+ current = current[idx]
+ except:
+ return default
+ else:
+ return default
+
+ return current if current not in (None, "", []) else default
+
+
+def collect_paths(prefix, obj, out: set):
+ """
+ Рекурсивно собирает все пути ключей:
+ lastName
+ season.points
+ carrier.points
+ season.0.points
+ """
+ if isinstance(obj, dict):
+ for k, v in obj.items():
+ new_prefix = f"{prefix}.{k}" if prefix else k
+ out.add(new_prefix)
+ collect_paths(new_prefix, v, out)
+ elif isinstance(obj, list):
+ for i, v in enumerate(obj):
+ new_prefix = f"{prefix}.{i}" if prefix else str(i)
+ out.add(new_prefix)
+ collect_paths(new_prefix, v, out)
+
+
+def extract_all_sort_paths(items: list[dict]) -> list[str]:
+ """
+ Собирает уникальные пути для сортировки из всех элементов milestones,
+ исключая нежелательные ключи.
+ """
+ paths: set[str] = set()
+ for item in items:
+ collect_paths("", item, paths)
+
+ # ключи, которые нужно исключить
+ EXCLUDE = {
+ "fastBreak",
+ "isStart",
+ "foulB",
+ "foulC",
+ "foulD",
+ "foulT",
+ "isDoubleDouble",
+ "isTripleDouble",
+ "pass",
+ "second",
+ "blockedOwnShot",
+ "playedTime",
+ }
+
+ filtered = []
+ for p in paths:
+ last_key = p.split(".")[-1]
+ if last_key not in EXCLUDE:
+ filtered.append(p)
+
+ return sorted(filtered)
+
+def _build_milestones() -> list[dict]:
+ """
+ Собирает milestones по всем игрокам обеих команд из pregame-full-stats.
+ Возвращает список словарей с полями:
+ lastName, firstName, team, season, career
+ где season / career — это dict со статистикой (обычно Sum).
+ """
+ players_stats_league = (latest_data.get("league_stats") or {}).get("data", {}).get(
+ "items"
+ ) or []
+ if not players_stats_league:
+ return []
+ players_stats_season = (latest_data.get("season_stats") or {}).get("data", {}).get(
+ "items"
+ ) or []
+ if not players_stats_season:
+ return []
+
+ milestones: list[dict] = []
+ excel_wrap = latest_data.get("excel_TEAMS_LEGEND").get("data") or []
+
+ for player in players_stats_league:
+ # все записи по этому игроку (обычно одна)
+ season_items = [
+ item
+ for item in players_stats_season
+ if item.get("personId") == player.get("personId")
+ ]
+ team = ""
+ display_number = 0
+ team_id = None
+
+ if season_items:
+ season_item = season_items[0]
+ base_season_stats = season_item.get("stats") or {}
+ season_games = season_item.get("games", 0)
+
+ season_stats = dict(base_season_stats)
+ season_stats["games"] = season_games
+
+ team_id = season_item.get("team", {}).get("id")
+ display_number = season_item.get("displayNumber", 0)
+ else:
+ season_stats = {}
+
+ # 🔍 Ищем название команды в excel_wrap
+ team_name = ""
+ if team_id:
+ for row in excel_wrap:
+ if int(row.get("Id")) == int(team_id):
+ team_name = row.get("Team", "")
+ break
+
+ # карьера из league_stats
+ base_career_stats = player.get("stats") or {}
+ career_games = player.get("games", 0)
+ career_stats = dict(base_career_stats)
+ career_stats["games"] = career_games
+
+ season_sum = season_stats # dict
+ career_sum = career_stats # dict
+ season_avg = compute_avg(season_sum)
+ career_avg = compute_avg(career_sum)
+
+ milestones.append(
+ {
+ "lastName": (player.get("person") or {}).get("lastName", ""),
+ "firstName": (player.get("person") or {}).get("firstName", ""),
+ "team": (
+ team_name if team_name != "" else player.get("team").get("name")
+ ),
+ "displayNumber": display_number,
+ "season": season_stats,
+ "career": career_stats,
+ "season_avg": season_avg,
+ "career_avg": career_avg,
+ }
+ )
+ return milestones
+
+
+def format_float(value):
+ """
+ Возвращает строку float всегда с дробной частью:
+ 12 -> "12.0"
+ 12.0 -> "12.0"
+ 12.5 -> "12.5"
+ None -> "0.0"
+ """
+ # пустые значения -> 0.0
+ if value in (None, "", [], {}):
+ return "0.0"
+
+ # если уже float или int
+ if isinstance(value, (int, float)):
+ val = float(value)
+ # всегда форматируем с дробной частью
+ return f"{val:.1f}"
+
+ # если строка
+ if isinstance(value, str):
+ try:
+ val = float(value.replace(",", "."))
+ return f"{val:.1f}"
+ except:
+ return "0.0"
+
+ # fallback
+ try:
+ val = float(value)
+ return f"{val:.1f}"
+ except:
+ return "0.0"
+
+
+def compress_milestone_for_vmix(m: dict) -> dict:
+ """
+ Лёгкая версия игрока для vMix:
+ только имя, фамилия, команда, выбранная статистика, её значение и игры.
+ """
+ data = latest_data["game"]["data"]["result"]
+ season_api = (
+ f'{str(data["league"]["season"]-1)}-{str(data["league"]["season"])[2:]}'
+ )
+
+ return {
+ "firstName": m.get("firstName", ""),
+ "lastName": m.get("lastName", ""),
+ "displayNumber": m.get("displayNumber", ""),
+ "photo": m.get("photo", ""),
+ "team": m.get("team", ""),
+ "logo_xls": m.get("logo_xls", ""),
+ "statName1": (
+ f"Regular season {season_api}"
+ if "season" in m.get("statName", "").split(".")[0]
+ else "VTB History"
+ ),
+ "statName2": (
+ f'{m.get("statName", "").split(".")[1].replace("points", "point")}s'
+ if "shot" not in m.get("statName", "").split(".")[1]
+ else m.get("statName", "").split(".")[1]
+ ),
+ "statValue": m.get("statValue", ""),
+ "games": m.get("games", ""),
+ }
+
+
+@app.get("/milestones")
+async def milestones(
+ sort_by: str | None = None,
+ direction: str | None = None,
+ vmix: bool = False, # 👈 добавили флаг
+):
+ global CURRENT_SORT
+
+ if sort_by is not None:
+ CURRENT_SORT["sort_by"] = sort_by
+
+ if direction is not None:
+ CURRENT_SORT["direction"] = direction
+
+ sort_by = CURRENT_SORT.get("sort_by")
+ direction = CURRENT_SORT.get("direction") or "desc"
+ milestones = _build_milestones()
+ if not milestones:
+ return [{"message": "Данных об истории команд нет!"}]
+
+ if sort_by:
+ reverse = direction != "asc"
+
+ def sort_key(x):
+ raw = get_nested_value(x, sort_by, 0)
+ return normalize_number(raw)
+
+ milestones = sorted(milestones, key=sort_key, reverse=reverse)
+
+ for m in milestones:
+ row_team = get_excel_row_for_team(m["team"])
+ team_logo_exl = row_team.get("TeamLogo", "")
+ m["statName"] = sort_by
+
+ # значение для сортировки
+ raw_val = sort_key(m)
+
+ # 🔹 Если сортировка по AVG — оставляем float с дробной частью
+ if sort_by.startswith("season_avg.") or sort_by.startswith("career_avg."):
+ m["statValue"] = format_float(raw_val)
+ else:
+ # 🔹 Не AVG — всегда целые числа, если это возможно
+ val = normalize_number(raw_val)
+ if isinstance(val, float) and val.is_integer():
+ val = int(val)
+ m["statValue"] = val
+
+ # 🆕 GAMES:
+ games_val = 0
+
+ if sort_by.startswith("season_avg."):
+ # сначала пробуем games из season_avg, если там нет — из season (Sum)
+ games_raw = get_nested_value(m, "season_avg.games", None)
+ if games_raw is None:
+ games_raw = get_nested_value(m, "season.games", 0)
+ games_val = normalize_number(games_raw)
+
+ elif sort_by.startswith("season."):
+ # обычный сезонный Sum
+ games_raw = get_nested_value(m, "season.games", 0)
+ games_val = normalize_number(games_raw)
+
+ elif sort_by.startswith("career_avg."):
+ # сначала career_avg.games, если пусто — career.games
+ games_raw = get_nested_value(m, "career_avg.games", None)
+ if games_raw is None:
+ games_raw = get_nested_value(m, "career.games", 0)
+ games_val = normalize_number(games_raw)
+
+ elif sort_by.startswith("carrier.") or sort_by.startswith("career."):
+ # старый путь carrier/career (Sum)
+ games_raw = get_nested_value(
+ m, "carrier.games", None
+ ) or get_nested_value(m, "career.games", 0)
+ games_val = normalize_number(games_raw)
+
+ # на всякий случай тоже дожмём до int, если вдруг float
+ if isinstance(games_val, float) and games_val.is_integer():
+ games_val = int(games_val)
+
+ m["games"] = games_val
+ m["logo_xls"] = team_logo_exl
+ m["photo"] = os.path.join(
+ "D:\\Photos",
+ "vtb",
+ m["team"],
+ f"{m.get('displayNumber')}.png",
+ )
+
+ else:
+ for m in milestones:
+ m["statName"] = None
+ m["statValue"] = None
+ m["games"] = None
+ m["logo_xls"] = None
+ m["photo"] = r"D:\Графика\БАСКЕТБОЛ\VTB League\ASSETS\EMPTY.png"
+ if vmix:
+ return [compress_milestone_for_vmix(m) for m in milestones[:20]]
+
+ return milestones
+
+
+def compute_avg(stats: dict) -> dict:
+ """
+ Принимает суммарную статистику игрока вида:
+ { "points": 120, "rebounds": 50, "games": 6, ... }
+
+ Возвращает словарь со средними значениями:
+ { "points": 20.0, "rebounds": 8.3, ... }
+
+ Формат всегда float с .0
+ """
+ if not isinstance(stats, dict):
+ return {}
+
+ games = stats.get("games", 0)
+ if not isinstance(games, (int, float)) or games == 0:
+ return {}
+
+ avg = {}
+ for key, value in stats.items():
+ if key == "games":
+ continue
+ if not isinstance(value, (int, float)):
+ continue
+
+ avg_val = value / games
+
+ # всегда форматируем как float с .0
+ if isinstance(avg_val, float) and avg_val.is_integer():
+ avg_val = float(f"{int(avg_val)}.0")
+ else:
+ avg_val = float(f"{avg_val:.1f}")
+
+ avg[key] = avg_val
+
+ return avg
+
+
+def normalize_number(value):
+ """
+ Превращает значение в корректное число:
+ - '12' -> 12
+ - '12.0' -> 12
+ - '12.5' -> 12.5
+ - None/'' -> 0
+ """
+ if value in (None, "", [], {}):
+ return 0
+
+ # если уже int
+ if isinstance(value, int):
+ return value
+
+ # если float
+ if isinstance(value, float):
+ # если выглядит как целое — сделать int
+ return int(value) if value.is_integer() else value
+
+ # если строка
+ if isinstance(value, str):
+ try:
+ v = float(value.replace(",", "."))
+ return int(v) if v.is_integer() else v
+ except:
+ return 0
+
+ # если что-то другое — неиспользуемый тип
+ return 0
+
+
+@app.get("/milestones/ui", response_class=HTMLResponse)
+async def milestones_ui():
+ milestones = _build_milestones()
+ keys = extract_all_sort_paths(milestones)
+ keys_js = json.dumps(keys, ensure_ascii=False)
+
+ html = """
+
+
+
+
+ Milestones
+
+
+
+
+
+
+
+
+ Milestones игроков
+ live
+
+
+ Слева — список игроков, справа — подробная статистика по 4 блокам (Season / Season Avg / Career / Career Avg).
+ Сортировку меняем выбором блока и показателя.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Выбери блок + показатель, чтобы отсортировать
+
+
+
+
+
+
+
+
+
+
+ Игрок
+ ↕
+
+
+ Команда
+ ↕
+
+
+
+ Статистика
+
+
+
+ Значение
+
+
+
+ Games
+
+
+
+ Сезон (очки)
+ ↕
+
+
+ Карьера (очки)
+ ↕
+
+
+ Сырые данные
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Выберите игрока в таблице
+
+
+
+
+
+
+
+
+
+ Сырые данные по игроку будут показаны здесь
+ в виде таблицы: строки — показатели, столбцы — Season / Season Avg / Career / Career Avg.
+