diff --git a/.gitignore b/.gitignore index a21c49b..5820b83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /__pycache__ /TestJson /logs/* -*.venv \ No newline at end of file +*.venv +*.env \ No newline at end of file diff --git a/get_data.py b/get_data.py index 62f0b09..734abb9 100644 --- a/get_data.py +++ b/get_data.py @@ -1,25 +1,20 @@ -from fastapi import FastAPI -from fastapi.responses import Response, HTMLResponse -from fastapi import HTTPException -from fastapi import Request +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import Response, HTMLResponse, FileResponse, StreamingResponse +from typing import Dict, Any from contextlib import asynccontextmanager -import requests -import threading -import time -import queue +import requests, uvicorn, json +import threading, queue import argparse -import uvicorn -import os import pandas as pd -import json from datetime import datetime, time as dtime, timedelta from fastapi.responses import Response import logging import logging.config -import platform -import socket - -# передадим параметры через аргументы или глобальные переменные +from dotenv import load_dotenv +from pprint import pprint +import nasio +import io, os, platform, time +import xml.etree.ElementTree as ET parser = argparse.ArgumentParser() parser = argparse.ArgumentParser() @@ -29,14 +24,12 @@ parser.add_argument("--lang", default="en") args = parser.parse_args() MYHOST = platform.node() -user_name = socket.gethostname() if not os.path.exists("logs"): os.makedirs("logs") -telegram_bot_token = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY" -# telegram_chat_id = 228977654 -telegram_chat_id = -4803699526 +telegram_bot_token = os.getenv("TELEGRAM_TOKEN") +telegram_chat_id = os.getenv("TELEGRAM_CHAT_ID") log_config = { "version": 1, "handlers": { @@ -67,7 +60,7 @@ log_config = { "formatters": { "telegram": { "class": "telegram_handler.HtmlFormatter", - "format": f"%(levelname)s [{MYHOST.upper()}] [{user_name}]\n%(message)s", + "format": f"%(levelname)s [{MYHOST.upper()}]\n%(message)s", "use_emoji": "True", }, "simple": { @@ -82,11 +75,19 @@ logging.config.dictConfig(log_config) logger = logging.getLogger(__name__) logger.handlers[2].formatter.use_emoji = True +pprint(f"Локальный файл окружения ={load_dotenv(verbose=True)}") LEAGUE = args.league TEAM = args.team LANG = args.lang -HOST = "https://pro.russiabasket.org" +HOST = os.getenv("API_BASE_URL") +SYNO_PATH = f'{os.getenv("SYNO_PATH")}MATCH INFO.xlsx' +SYNO_URL = os.getenv("SYNO_URL") +SYNO_USERNAME = os.getenv("SYNO_USERNAME") +SYNO_PASSWORD = os.getenv("SYNO_PASSWORD") +SYNO_PATH_МVMIX = os.getenv("SYNO_PATH_МVMIX") + + STATUS = False GAME_ID = None SEASON = None @@ -95,6 +96,12 @@ GAME_TODAY = False # флаг: игра сегодня GAME_SOON = False # флаг: игра сегодня и скоро (<1 часа) OFFLINE_SWITCH_AT = None # timestamp, когда надо уйти в оффлайн OFFLINE_DELAY_SEC = 600 # 10 минут +SLEEP_NOTICE_SENT = False # 👈 чтобы не слать уведомление повторно +# --- preload lock --- +PRELOAD_LOCK = False # когда True — consumer будет принимать только preloaded game +PRELOADED_GAME_ID = None # ID матча, который мы держим «тёплым» +PRELOAD_HOLD_UNTIL = None # timestamp, до какого момента держим (T-1:15) + # общая очередь results_q = queue.Queue() @@ -113,6 +120,8 @@ threads_offline = [] # какой режим сейчас запущен: "live" / "offline" / None CURRENT_THREADS_MODE = None +CLEAR_OUTPUT_FOR_VMIX = False +EMPTY_PHOTO_PATH = r"D:\Графика\БАСКЕТБОЛ\VTB League\ASSETS\EMPTY.png" URLS = { @@ -128,6 +137,17 @@ URLS = { } +def maybe_clear_for_vmix(payload): + """ + Если включён режим очистки — возвращаем payload, + где все значения заменены на "". + Иначе — возвращаем как есть. + """ + if CLEAR_OUTPUT_FOR_VMIX: + return wipe_json_values(payload) + return payload + + def start_offline_threads(season, game_id): """Запускаем редкие запросы, когда матча нет или он уже сыгран.""" global threads_offline, CURRENT_THREADS_MODE, stop_event_offline, latest_data @@ -137,18 +157,8 @@ def start_offline_threads(season, game_id): return stop_live_threads() + stop_offline_threads() logger.info("[threads] switching to OFFLINE mode ...") - # 🔹 очищаем latest_data безопасно, чтобы не ломать структуру - keep_keys = { - "game", - "pregame", - "pregame-full-stats", - "actual-standings", - "calendar", - } - for key in list(latest_data.keys()): - if key not in keep_keys: - del latest_data[key] stop_event_offline.clear() @@ -165,6 +175,34 @@ def start_offline_threads(season, game_id): ), daemon=True, ), + threading.Thread( + target=get_data_from_API, + args=( + "pregame-full-stats", + URLS["pregame-full-stats"].format( + host=HOST, league=LEAGUE, season=season, game_id=game_id, lang=LANG + ), + 600, + stop_event_offline, + False, + True, + ), + daemon=True, + ), + threading.Thread( + target=get_data_from_API, + args=( + "actual-standings", + URLS["actual-standings"].format( + host=HOST, league=LEAGUE, season=season, lang=LANG + ), + 600, + stop_event_offline, + False, + True, + ), + daemon=True, + ), ] for t in threads_offline: t.start() @@ -300,6 +338,7 @@ def stop_live_threads(): ) else: logger.info("[threads] LIVE threads stopped") + CURRENT_THREADS_MODE = None # 👈 сбрасываем режим def stop_offline_threads(): @@ -311,6 +350,7 @@ def stop_offline_threads(): for t in threads_offline: t.join(timeout=1) threads_offline = [] + CURRENT_THREADS_MODE = None # 👈 сбрасываем режим logger.info("[threads] OFFLINE threads stopped") @@ -326,6 +366,7 @@ def has_full_game_ready() -> bool: and "teams" in payload["data"]["result"] ) + # Функция запускаемая в потоках def get_data_from_API( name: str, @@ -333,15 +374,21 @@ def get_data_from_API( sleep_time: float, stop_event: threading.Event, stop_when_live=False, - stop_after_success: bool = False, # 👈 новый флаг + stop_after_success: bool = False, # 👈 новый флаг ): did_first_fetch = False while not stop_event.is_set(): current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") - if stop_when_live and globals().get("STATUS") == "live" and has_full_game_ready(): - logger.info(f"{[{current_time}]} [{name}] stopping because STATUS='live' and full game is ready") + if ( + stop_when_live + and globals().get("STATUS") == "live" + and has_full_game_ready() + ): + logger.info( + f"{[{current_time}]} [{name}] stopping because STATUS='live' and full game is ready" + ) break - + try: value = requests.get(url, timeout=5).json() did_first_fetch = True # помечаем, что один заход сделали @@ -379,7 +426,9 @@ def get_data_from_API( ) if stop_after_success and ok_status: - logger.info(f"[{name}] got successful response → stopping thread (stop_after_success)") + logger.info( + f"[{name}] got successful response → stopping thread (stop_after_success)" + ) return # сколько уже заняло @@ -393,8 +442,14 @@ def get_data_from_API( while slept < sleep_time: if stop_event.is_set(): break - if stop_when_live and globals().get("STATUS") == "live" and has_full_game_ready(): - logger.info(f"[{name}] stopping during sleep because STATUS='live' and full game is ready") + if ( + stop_when_live + and globals().get("STATUS") == "live" + and has_full_game_ready() + ): + logger.info( + f"[{name}] stopping during sleep because STATUS='live' and full game is ready" + ) return time.sleep(1) @@ -429,8 +484,19 @@ def results_consumer(): incoming_status = payload.get("status") # может быть None # print(source, incoming_status) if source == "game": - # обработка ТОЛЬКО в спец-ветке ниже - pass + # принимаем ТОЛЬКО тот game_id, который держим в PRELOAD_LOCK + try: + if PRELOAD_LOCK: + incoming_gid = extract_game_id_from_payload(payload) + if not incoming_gid or str(incoming_gid) != str( + PRELOADED_GAME_ID + ): + logger.debug( + f"results_consumer: skip game (gid={incoming_gid}) due to PRELOAD_LOCK; keep {PRELOADED_GAME_ID}" + ) + continue + except Exception as _e: + logger.debug(f"results_consumer: preload lock check error: {_e}") else: latest_data[source] = { "ts": msg["ts"], @@ -540,8 +606,14 @@ def results_consumer(): and GAME_START_DT.date() == datetime.now().date() ): globals()["STATUS"] = "finished_wait" + globals()[ + "CLEAR_OUTPUT_FOR_VMIX" + ] = True # 👈 включаем режим "пустых" данных else: globals()["STATUS"] = "finished_wait" + globals()[ + "CLEAR_OUTPUT_FOR_VMIX" + ] = True # 👈 включаем режим "пустых" данных human_time = datetime.fromtimestamp(switch_at).strftime( "%H:%M:%S" @@ -561,6 +633,9 @@ def results_consumer(): "online" in raw_ls_status_low or "live" in raw_ls_status_low ): # если до этого стояла отложка — уберём + globals()[ + "CLEAR_OUTPUT_FOR_VMIX" + ] = False # 👈 выключаем очистку if globals().get("OFFLINE_SWITCH_AT") is not None: logger.info( "[status] match back to LIVE → cancel scheduled OFFLINE" @@ -581,7 +656,9 @@ def results_consumer(): else: if source == "game": - has_game_already = "game" in latest_data and isinstance(latest_data.get("game"), dict) + has_game_already = "game" in latest_data and isinstance( + latest_data.get("game"), dict + ) # Полная структура? is_full = ( @@ -597,34 +674,25 @@ def results_consumer(): # чтобы /status видел "живость" раз в 5 минут независимо от полноты JSON. if globals().get("STATUS") != "live": latest_data["game"] = {"ts": msg["ts"], "data": payload} - logger.debug("results_consumer: pre-live game → updated (full=%s)", is_full) + logger.debug( + "results_consumer: pre-live game → updated (full=%s)", + is_full, + ) else: # ✅ если игры ещё НЕТ в кэше — примем ПЕРВЫЙ game даже неполный, # чтобы box-score/play-by-play могли его дорастить if is_full or not has_game_already: latest_data["game"] = {"ts": msg["ts"], "data": payload} - logger.debug("results_consumer: LIVE → stored (full=%s, had=%s)", is_full, has_game_already) + logger.debug( + "results_consumer: LIVE → stored (full=%s, had=%s)", + is_full, + has_game_already, + ) else: - logger.debug("results_consumer: LIVE & partial game → keep previous one") - - # 2) Когда матч УЖЕ online (STATUS == 'live'): - # - поток 'game' в live-режиме погаснет сам (stop_when_live=True), - # но если вдруг что-то долетит, кладём только полный JSON. + logger.debug( + "results_consumer: LIVE & partial game → keep previous one" + ) continue - # # game неполный - # if not has_game_already: - # # 👉 раньше game вообще не было — лучше положить хоть что-то - # latest_data["game"] = { - # "ts": msg["ts"], - # "data": payload, - # } - # else: - # # 👉 уже есть какой-то game — неполным НЕ затираем - # logger.debug( - # "results_consumer: got partial game, keeping previous one" - # ) - - # и обязательно continue/return из этого elif/if else: latest_data[source] = { "ts": msg["ts"], @@ -735,8 +803,9 @@ def build_pretty_status_message(): Если game ещё нет — шлём хотя бы статусы источников. """ lines = [] + cgid = get_cached_game_id() lines.append(f"🏀 {LEAGUE.upper()} • {TEAM}") - lines.append(f"📌 Game ID: {GAME_ID}") + lines.append(f"📌 Game ID: {cgid or GAME_ID}") lines.append(f"🕒 {GAME_START_DT}") # сначала попробуем собрать нормальный game @@ -869,42 +938,253 @@ def status_broadcaster(): time.sleep(1) +def get_cached_game_id() -> str | None: + game = latest_data.get("game") + if not game: + return None + payload = game.get("data", game) + if not isinstance(payload, dict): + return None + # структура может быть {"data":{"result":{...}}} или {"result":{...}} + result = ( + payload.get("data", {}).get("result") + if "data" in payload + else payload.get("result") + ) + if not isinstance(result, dict): + return None + g = result.get("game") + if isinstance(g, dict): + return g.get("id") + return None + + +def extract_game_id_from_payload(payload: dict) -> str | None: + if not isinstance(payload, dict): + return None + root = payload.get("data") if isinstance(payload.get("data"), dict) else payload + res = root.get("result") if isinstance(root.get("result"), dict) else None + if not isinstance(res, dict): + return None + g = res.get("game") + if isinstance(g, dict): + return g.get("id") + return None + + +def start_offline_prevgame(season, prev_game_id: str): + """ + Специальный оффлайн для ПРЕДЫДУЩЕЙ игры: + - гасит любые текущие треды + - запускает только 'game' для prev_game_id + - НЕ останавливается после первого 'ok' (stop_after_success=False) + """ + global threads_offline, CURRENT_THREADS_MODE, stop_event_offline, latest_data + + # всегда переключаемся чисто + stop_live_threads() + stop_offline_threads() + + logger.info("[threads] switching to OFFLINE mode (previous game) ...") + + stop_event_offline.clear() + threads_offline = [ + threading.Thread( + target=get_data_from_API, + args=( + "game", + URLS["game"].format(host=HOST, game_id=prev_game_id, lang=LANG), + 300, # редкий опрос + stop_event_offline, + False, # stop_when_live + False, # ✅ stop_after_success=False (держим тред) + ), + daemon=True, + ), + threading.Thread( + target=get_data_from_API, + args=( + "pregame-full-stats", + URLS["pregame-full-stats"].format( + host=HOST, + league=LEAGUE, + season=season, + game_id=prev_game_id, + lang=LANG, + ), + 600, + stop_event_offline, + False, + False, + ), + daemon=True, + ), + threading.Thread( + target=get_data_from_API, + args=( + "actual-standings", + URLS["actual-standings"].format( + host=HOST, league=LEAGUE, season=season, lang=LANG + ), + 600, + stop_event_offline, + False, + False, + ), + daemon=True, + ), + ] + for t in threads_offline: + t.start() + + CURRENT_THREADS_MODE = "offline" + logger.info(f"[threads] OFFLINE prev-game thread started for {prev_game_id}") + + def start_prestart_watcher(game_dt: datetime | None): """ - Следит за временем начала игры. - Как только до матча остаётся <= 1ч10м — включает live-треды. - Работает только для "игра сегодня". + Логика на день игры: + 1) Немедленно подгружаем ДАННЫЕ ПРОШЛОГО МАТЧА (один раз, оффлайн-поток 'game'), + чтобы программа имела данные до старта. + 2) Ровно за 1:15 до начала — СБРАСЫВАЕМ эти данные (останавливаем оффлайн, чистим latest_data). + 3) Ровно за 1:10 до начала — ВКЛЮЧАЕМ LIVE-треды. """ if not game_dt: - return # нечего ждать + return + + # разовое уведомление о "спячке" + global SLEEP_NOTICE_SENT, STATUS, SEASON, GAME_ID + now = datetime.now() + if not SLEEP_NOTICE_SENT and game_dt > now: + logger.info( + "🛌 Тред ушёл в спячку до начала игры.\n" + f"⏰ Матч начинается сегодня в {game_dt.strftime('%H:%M')}." + ) + SLEEP_NOTICE_SENT = True def _runner(): - global STATUS - # за сколько включать live - lead = timedelta(hours=1, minutes=10) - switch_at = game_dt - lead + from datetime import time as dtime # для резервного парсинга времени + global STATUS + + PRELOAD_LEAD = timedelta(hours=1, minutes=15) # T-1:15 → сброс + LIVE_LEAD = timedelta(hours=1, minutes=10) # T-1:10 → live + RESET_AT = game_dt - PRELOAD_LEAD + LIVE_AT = game_dt - LIVE_LEAD + PRELOAD_MAXWAIT_SEC = 180 # ждём до 3 мин готовности full game при предзагрузке + + did_preload = False + did_reset = False + did_live = False + + # --- вспомогательное: поиск предыдущей игры команды ДО сегодняшнего матча --- + def _find_prev_game_id( + calendar_json: dict, cutoff_dt: datetime + ) -> tuple[str | None, datetime | None]: + items = get_items(calendar_json) or [] + prev_id, prev_dt = None, None + team_norm = (TEAM or "").strip().casefold() + for g in reversed(items): + try: + t1 = (g["team1"]["name"] or "").strip().casefold() + t2 = (g["team2"]["name"] or "").strip().casefold() + if team_norm not in (t1, t2): + continue + except Exception: + continue + gdt = extract_game_datetime(g) + if not gdt: + try: + gd = datetime.strptime( + g["game"]["localDate"], "%d.%m.%Y" + ).date() + gdt = datetime.combine(gd, dtime(0, 0)) + except Exception: + continue + if gdt < cutoff_dt: + prev_id, prev_dt = g["game"]["id"], gdt + break + return prev_id, prev_dt + + # --- Шаг 1: сразу включаем оффлайн по ПРЕДЫДУЩЕЙ игре и держим до T-1:15 --- + try: + now = datetime.now() + if now < RESET_AT: + calendar_resp = requests.get( + URLS["calendar"].format( + host=HOST, league=LEAGUE, season=SEASON, lang=LANG + ), + timeout=6, + ).json() + prev_game_id, prev_game_dt = _find_prev_game_id(calendar_resp, game_dt) + if prev_game_id and str(prev_game_id) != str(GAME_ID): + logger.info( + f"[preload] старт оффлайна по предыдущей игре {prev_game_id} ({prev_game_dt})" + ) + + # включаем «замок», чтобы consumer принимал только старую игру + globals()["PRELOAD_LOCK"] = True + globals()["PRELOADED_GAME_ID"] = str(prev_game_id) + globals()["PRELOAD_HOLD_UNTIL"] = RESET_AT.timestamp() + + # поднимаем один оффлайн-тред по старой игре (без stop_after_success) + start_offline_prevgame(SEASON, prev_game_id) + did_preload = True + else: + logger.warning("[preload] предыдущая игра не найдена — пропускаем") + else: + logger.info( + "[preload] уже поздно для предзагрузки (прошло T-1:15) — пропуск" + ) + except Exception as e: + logger.warning(f"[preload] ошибка предзагрузки прошлой игры: {e}") + + # --- Основной цикл ожидания контрольных моментов --- while not stop_event.is_set(): now = datetime.now() - # если игра уже live/finished — не мешаем + # если матч уже в другом конечном состоянии — выходим if STATUS in ("live", "finished", "finished_wait", "finished_today"): break - # если время подошло — включаем live и выходим - if now >= switch_at: + # Шаг 2: ровно T-1:15 — сбрасываем предзагруженные данные + if not did_reset and now >= RESET_AT: logger.info( - f"[prestart] it's {now}, game at {game_dt}, enabling LIVE threads (1h10m rule)" + f"[reset] {now:%H:%M:%S} → T-1:15: сбрасываем предзагруженные данные" + ) + try: + stop_offline_threads() # на всякий + # for key in latest_data: + # latest_data[key] = wipe_json_values(latest_data[key]) + # latest_data.clear() # полный сброс кэша + # снять замок предзагрузки + globals()["PRELOAD_LOCK"] = False + globals()["PRELOADED_GAME_ID"] = None + globals()["PRELOAD_HOLD_UNTIL"] = None + + logger.info( + "[reset] latest_data очищен; ждём T-1:10 для запуска live" + ) + except Exception as e: + logger.warning(f"[reset] ошибка при очистке: {e}") + globals()["CLEAR_OUTPUT_FOR_VMIX"] = True + did_reset = True + + # Шаг 3: T-1:10 — включаем live-треды + if not did_live and now >= LIVE_AT: + logger.info( + f"[prestart] {now:%H:%M:%S}, игра в {game_dt:%H:%M}, включаем LIVE threads по правилу T-1:10" ) STATUS = "live_soon" - # сначала гасим оффлайн, если он крутится - stop_offline_threads() - # а потом включаем live + globals()[ + "CLEAR_OUTPUT_FOR_VMIX" + ] = False # можно оставить пустоту до первых живых данных + stop_offline_threads() # на всякий случай start_live_threads(SEASON, GAME_ID) + did_live = True break - # иначе спим немного и проверяем снова - time.sleep(30) # можно 15–60 сек + time.sleep(15) t = threading.Thread(target=_runner, daemon=True) t.start() @@ -947,9 +1227,7 @@ async def lifespan(app: FastAPI): GAME_START_DT = game_dt GAME_TODAY = is_today - logger.info( - f"Лига: {LEAGUE}\nСезон: {season}\nКоманда: {TEAM}\nGame ID: {game_id}" - ) + logger.info(f"Лига: {LEAGUE}\nСезон: {season}\nКоманда: {TEAM}\nGame ID: {game_id}") # 4. запускаем "длинные" потоки (они у тебя и так всегда) thread_result_consumer = threading.Thread( @@ -986,10 +1264,10 @@ async def lifespan(app: FastAPI): start_live_threads(SEASON, GAME_ID) else: STATUS = "today_not_started" - start_offline_threads(SEASON, GAME_ID) + # start_offline_threads(SEASON, GAME_ID) else: STATUS = "today_not_started" - start_offline_threads(SEASON, GAME_ID) + # start_offline_threads(SEASON, GAME_ID) elif cal_status == "Online": STATUS = "live" @@ -1013,9 +1291,9 @@ async def lifespan(app: FastAPI): app = FastAPI( lifespan=lifespan, - docs_url=None, # ❌ отключает /docs - redoc_url=None, # ❌ отключает /redoc - openapi_url=None # ❌ отключает /openapi.json + docs_url=None, # ❌ отключает /docs + redoc_url=None, # ❌ отключает /redoc + openapi_url=None, # ❌ отключает /openapi.json ) @@ -1039,8 +1317,10 @@ def format_time(seconds: float | int) -> str: async def team1(): game = get_latest_game_safe("game") if not game: + # если данных вообще нет (ещё ни одной игры) — тут реально нечего отдавать raise HTTPException(status_code=503, detail="game data not ready") - return await team("team1") + data = await team("team1") + return maybe_clear_for_vmix(data) @app.get("/team2") @@ -1048,36 +1328,118 @@ async def team2(): game = get_latest_game_safe("game") if not game: raise HTTPException(status_code=503, detail="game data not ready") - return await team("team2") + data = await team("team2") + return maybe_clear_for_vmix(data) @app.get("/top_team1") async def top_team1(): data = await team("team1") - return await top_sorted_team(data) + top = await top_sorted_team(data) + return maybe_clear_for_vmix(top) @app.get("/top_team2") async def top_team2(): data = await team("team2") - return await top_sorted_team(data) + top = await top_sorted_team(data) + return maybe_clear_for_vmix(top) + + +def _b(v) -> bool: + if isinstance(v, bool): + return v + if isinstance(v, (int, float)): + return v != 0 + if isinstance(v, str): + return v.strip().lower() in ("1", "true", "yes", "on") + return False + + +def _placeholders(n=5): + return [ + { + "NameGFX": "", + "Name1GFX": "", + "Name2GFX": "", + "isOnCourt": False, + "num": "", + "photoGFX": EMPTY_PHOTO_PATH, + } + for _ in range(n) + ] + + +def wipe_json_values(obj): + """ + Рекурсивно заменяет все значения JSON на пустые строки. + Если ключ содержит "photo", заменяет значение на EMPTY_PHOTO_PATH. + """ + # если словарь — обрабатываем ключи + if isinstance(obj, dict): + new_dict = {} + for k, v in obj.items(): + if "photo" in str(k).lower(): + # ключ содержит photo → отдаём пустую картинку + new_dict[k] = EMPTY_PHOTO_PATH + else: + new_dict[k] = wipe_json_values(v) + return new_dict + + # если список — рекурсивно обработать элементы + elif isinstance(obj, list): + return [wipe_json_values(v) for v in obj] + + # любое конечное значение → "" + else: + return "" @app.get("/started_team1") -async def started_team1(): +async def started_team1(sort_by: str = None): data = await team("team1") - return await started_team(data) + players = await started_team(data) or [] + + # нормализуем флаги + for p in players: + p["isStart"] = _b(p.get("isStart", False)) + p["isOnCourt"] = _b(p.get("isOnCourt", False)) + + if sort_by and sort_by.strip().lower() == "isstart": + starters = [p for p in players if p["isStart"]] + return maybe_clear_for_vmix(starters[:5] if starters else _placeholders(5)) + + if sort_by and sort_by.strip().lower() == "isoncourt": + on_court = [p for p in players if p["isOnCourt"]] + return maybe_clear_for_vmix(on_court[:5] if on_court else _placeholders(5)) + + # дефолт — без фильтра, как раньше + return maybe_clear_for_vmix(players) @app.get("/started_team2") -async def started_team2(): +async def started_team2(sort_by: str = None): data = await team("team2") - return await started_team(data) + players = await started_team(data) or [] + + for p in players: + p["isStart"] = _b(p.get("isStart", False)) + p["isOnCourt"] = _b(p.get("isOnCourt", False)) + + if sort_by and sort_by.strip().lower() == "isstart": + starters = [p for p in players if p["isStart"]] + return maybe_clear_for_vmix(starters[:5] if starters else _placeholders(5)) + + if sort_by and sort_by.strip().lower() == "isoncourt": + on_court = [p for p in players if p["isOnCourt"]] + return maybe_clear_for_vmix(on_court[:5] if on_court else _placeholders(5)) + + return maybe_clear_for_vmix(players) -@app.get("/game") +@app.get("/latest_data") async def game(): - return latest_data["game"] + return latest_data @app.get("/status") @@ -1108,10 +1470,16 @@ async def status(request: Request): sorted_keys = [k for k in sort_order if k in latest_data] + sorted( [k for k in latest_data if k not in sort_order] ) + cached_game_id = get_cached_game_id() or GAME_ID + note = "" + if cached_game_id and GAME_ID and str(cached_game_id) != str(GAME_ID): + note = ( + f' (предзагружены данные прошлой игры)' + ) data = { "league": LEAGUE, "team": TEAM, - "game_id": GAME_ID, + "game_id": cached_game_id, "game_status": STATUS, "statuses": [ { @@ -1139,7 +1507,7 @@ async def status(request: Request): league=LEAGUE, season=SEASON, lang=LANG, - game_id=GAME_ID, + game_id=cached_game_id, ), "color": color_for_status( latest_data[item]["data"]["status"] @@ -1223,7 +1591,7 @@ async def status(request: Request):

League: {LEAGUE}

Team: {TEAM}

-

Game ID: {GAME_ID}

+

Game ID: {cached_game_id}{note}

Game Status: {gs_text}

@@ -1269,7 +1637,7 @@ async def status(request: Request): formatted = json.dumps(data, indent=4, ensure_ascii=False) response = Response(content=formatted, media_type="application/json") response.headers["Refresh"] = "1" - return response + return maybe_clear_for_vmix(response) @app.get("/scores") @@ -1310,7 +1678,7 @@ async def scores(): score_by_quarter[i]["score1"] = parts[0] score_by_quarter[i]["score2"] = parts[1] - return score_by_quarter + return maybe_clear_for_vmix(score_by_quarter) async def top_sorted_team(data): @@ -1336,7 +1704,7 @@ async def top_sorted_team(data): return top_sorted_team -def get_latest_game_safe(name:str): +def get_latest_game_safe(name: str): """ Безопасно достаём актуальный game из latest_data. Возвращаем None, если структура ещё не готова или прилетел "плохой" game @@ -1365,25 +1733,6 @@ def get_latest_game_safe(name:str): return game -def format_time(seconds: float | int) -> str: - """ - Форматирует время в секундах в строку "M:SS". - - Args: - seconds (float | int): Количество секунд. - - Returns: - str: Время в формате "M:SS". - """ - try: - total_seconds = int(float(seconds)) - minutes = total_seconds // 60 - sec = total_seconds % 60 - return f"{minutes}:{sec:02}" - except (ValueError, TypeError): - return "0:00" - - def _pick_last_avg_and_sum(stats_list: list) -> tuple[dict, dict]: """Возвращает (season_sum, season_avg) из seasonStats. Безопасно при пустых данных.""" if not isinstance(stats_list, list) or len(stats_list) == 0: @@ -1392,15 +1741,31 @@ def _pick_last_avg_and_sum(stats_list: list) -> tuple[dict, dict]: last = stats_list[-1] if stats_list else None prev = stats_list[-2] if len(stats_list) >= 2 else None - season_avg = last.get("stats", {}) if isinstance(last, dict) and str(last.get("class")).lower() == "avg" else {} - season_sum = prev.get("stats", {}) if isinstance(prev, dict) and str(prev.get("class")).lower() == "sum" else {} + season_avg = ( + last.get("stats", {}) + if isinstance(last, dict) and str(last.get("class")).lower() == "avg" + else {} + ) + season_sum = ( + prev.get("stats", {}) + if isinstance(prev, dict) and str(prev.get("class")).lower() == "sum" + else {} + ) # Бывают инверсии порядка (на всякий случай): попробуем найти явно if not season_avg or not season_sum: for x in reversed(stats_list): - if isinstance(x, dict) and str(x.get("class")).lower() == "avg" and not season_avg: + if ( + isinstance(x, dict) + and str(x.get("class")).lower() == "avg" + and not season_avg + ): season_avg = x.get("stats", {}) or {} - if isinstance(x, dict) and str(x.get("class")).lower() == "sum" and not season_sum: + if ( + isinstance(x, dict) + and str(x.get("class")).lower() == "sum" + and not season_sum + ): season_sum = x.get("stats", {}) or {} if season_avg and season_sum: break @@ -1451,11 +1816,13 @@ async def team(who: str): if not game: # игра ещё не подгружена или структура кривоватая raise HTTPException(status_code=503, detail="game data not ready") - + full_stat = get_latest_game_safe("pregame-full-stats") if not full_stat: # ⚠️ full_stat_data отсутствует — работаем только с game_data - logger.debug(f"[{who}] full_stat_data not found → continuing with game_data only") + logger.debug( + f"[{who}] full_stat_data not found → continuing with game_data only" + ) full_stat_data = {} else: full_stat_data = full_stat["data"] if "data" in full_stat else full_stat @@ -1466,7 +1833,7 @@ async def team(who: str): "result" ] # здесь уже безопасно, мы проверили в get_latest_game_safe result_full = full_stat_data.get("result", {}) if full_stat_data else {} - + # в result ожидаем "teams" teams = result.get("teams") @@ -1498,87 +1865,93 @@ async def team(who: str): ("Forward-Center", "FC"), ] starts = payload.get("starts", []) - + team_rows = [] for item in starts: stats = item.get("stats") or {} pid = str(item.get("personId")) - full_obj = next((p for p in (payload_full or []) if str(p.get("personId")) == pid), None) + full_obj = next( + (p for p in (payload_full or []) if str(p.get("personId")) == pid), None + ) season_sum = season_avg = career_sum = career_avg = {} if full_obj: # сезон - season_sum, season_avg = _pick_last_avg_and_sum(full_obj.get("seasonStats") or []) + season_sum, season_avg = _pick_last_avg_and_sum( + full_obj.get("seasonStats") or [] + ) # карьера - career_sum, career_avg = _pick_career_sum_and_avg(full_obj.get("carrier") or []) - + career_sum, career_avg = _pick_career_sum_and_avg( + full_obj.get("carrier") or [] + ) + season_sum = _safe(season_sum) season_avg = _safe(season_avg) career_sum = _safe(career_sum) career_avg = _safe(career_avg) - + # Полезные числа для Totals+Live # live-поля в box-score называются goal1/2/3, shot1/2/3, defReb/offReb и т.п. - g1 = _as_int(stats.get("goal1")) - s1 = _as_int(stats.get("shot1")) - g2 = _as_int(stats.get("goal2")) - s2 = _as_int(stats.get("shot2")) - g3 = _as_int(stats.get("goal3")) - s3 = _as_int(stats.get("shot3")) + g1 = _as_int(stats.get("goal1")) + s1 = _as_int(stats.get("shot1")) + g2 = _as_int(stats.get("goal2")) + s2 = _as_int(stats.get("shot2")) + g3 = _as_int(stats.get("goal3")) + s3 = _as_int(stats.get("shot3")) # Сезонные суммы из pregame-full-stats - ss_pts = _as_int(season_sum.get("points")) - ss_ast = _as_int(season_sum.get("assist")) - ss_blk = _as_int(season_sum.get("blockShot")) + ss_pts = _as_int(season_sum.get("points")) + ss_ast = _as_int(season_sum.get("assist")) + ss_blk = _as_int(season_sum.get("blockShot")) ss_dreb = _as_int(season_sum.get("defRebound")) ss_oreb = _as_int(season_sum.get("offRebound")) - ss_reb = _as_int(season_sum.get("rebound")) - ss_stl = _as_int(season_sum.get("steal")) - ss_to = _as_int(season_sum.get("turnover")) + ss_reb = _as_int(season_sum.get("rebound")) + ss_stl = _as_int(season_sum.get("steal")) + ss_to = _as_int(season_sum.get("turnover")) ss_foul = _as_int(season_sum.get("foul")) - ss_sec = _as_int(season_sum.get("second")) - ss_gms = _as_int(season_sum.get("games")) - ss_st = _as_int(season_sum.get("isStarts")) - ss_g1 = _as_int(season_sum.get("goal1")) - ss_s1 = _as_int(season_sum.get("shot1")) - ss_g2 = _as_int(season_sum.get("goal2")) - ss_s2 = _as_int(season_sum.get("shot2")) - ss_g3 = _as_int(season_sum.get("goal3")) - ss_s3 = _as_int(season_sum.get("shot3")) - + ss_sec = _as_int(season_sum.get("second")) + ss_gms = _as_int(season_sum.get("games")) + ss_st = _as_int(season_sum.get("isStarts")) + ss_g1 = _as_int(season_sum.get("goal1")) + ss_s1 = _as_int(season_sum.get("shot1")) + ss_g2 = _as_int(season_sum.get("goal2")) + ss_s2 = _as_int(season_sum.get("shot2")) + ss_g3 = _as_int(season_sum.get("goal3")) + ss_s3 = _as_int(season_sum.get("shot3")) + # Карьерные суммы из pregame-full-stats - car_ss_pts = _as_int(career_sum.get("points")) - car_ss_ast = _as_int(career_sum.get("assist")) - car_ss_blk = _as_int(career_sum.get("blockShot")) + car_ss_pts = _as_int(career_sum.get("points")) + car_ss_ast = _as_int(career_sum.get("assist")) + car_ss_blk = _as_int(career_sum.get("blockShot")) car_ss_dreb = _as_int(career_sum.get("defRebound")) car_ss_oreb = _as_int(career_sum.get("offRebound")) - car_ss_reb = _as_int(career_sum.get("rebound")) - car_ss_stl = _as_int(career_sum.get("steal")) - car_ss_to = _as_int(career_sum.get("turnover")) + car_ss_reb = _as_int(career_sum.get("rebound")) + car_ss_stl = _as_int(career_sum.get("steal")) + car_ss_to = _as_int(career_sum.get("turnover")) car_ss_foul = _as_int(career_sum.get("foul")) - car_ss_sec = _as_int(career_sum.get("second")) - car_ss_gms = _as_int(career_sum.get("games")) - car_ss_st = _as_int(career_sum.get("isStarts")) - car_ss_g1 = _as_int(career_sum.get("goal1")) - car_ss_s1 = _as_int(career_sum.get("shot1")) - car_ss_g2 = _as_int(career_sum.get("goal2")) - car_ss_s2 = _as_int(career_sum.get("shot2")) - car_ss_g3 = _as_int(career_sum.get("goal3")) - car_ss_s3 = _as_int(career_sum.get("shot3")) + car_ss_sec = _as_int(career_sum.get("second")) + car_ss_gms = _as_int(career_sum.get("games")) + car_ss_st = _as_int(career_sum.get("isStarts")) + car_ss_g1 = _as_int(career_sum.get("goal1")) + car_ss_s1 = _as_int(career_sum.get("shot1")) + car_ss_g2 = _as_int(career_sum.get("goal2")) + car_ss_s2 = _as_int(career_sum.get("shot2")) + car_ss_g3 = _as_int(career_sum.get("goal3")) + car_ss_s3 = _as_int(career_sum.get("shot3")) # Totals по сезону, «с учётом текущего матча»: T_points = ss_pts + _as_int(stats.get("points")) T_assist = ss_ast + _as_int(stats.get("assist")) - T_block = ss_blk + _as_int(stats.get("block")) - T_dreb = ss_dreb + _as_int(stats.get("defReb")) - T_oreb = ss_oreb + _as_int(stats.get("offReb")) - T_reb = ss_reb + (_as_int(stats.get("defReb")) + _as_int(stats.get("offReb"))) - T_steal = ss_stl + _as_int(stats.get("steal")) - T_turn = ss_to + _as_int(stats.get("turnover")) - T_foul = ss_foul + _as_int(stats.get("foul")) - T_sec = ss_sec + _as_int(stats.get("second")) - T_gms = ss_gms + (1 if _as_int(stats.get("second")) > 0 else 0) - T_starts = ss_st + (1 if bool(stats.get("isStart")) else 0) + T_block = ss_blk + _as_int(stats.get("block")) + T_dreb = ss_dreb + _as_int(stats.get("defReb")) + T_oreb = ss_oreb + _as_int(stats.get("offReb")) + T_reb = ss_reb + (_as_int(stats.get("defReb")) + _as_int(stats.get("offReb"))) + T_steal = ss_stl + _as_int(stats.get("steal")) + T_turn = ss_to + _as_int(stats.get("turnover")) + T_foul = ss_foul + _as_int(stats.get("foul")) + T_sec = ss_sec + _as_int(stats.get("second")) + T_gms = ss_gms + (1 if _as_int(stats.get("second")) > 0 else 0) + T_starts = ss_st + (1 if bool(stats.get("isStart")) else 0) T_g1 = ss_g1 + g1 T_s1 = ss_s1 + s1 @@ -1586,20 +1959,22 @@ async def team(who: str): T_s2 = ss_s2 + s2 T_g3 = ss_g3 + g3 T_s3 = ss_s3 + s3 - + # Totals по карьере, «с учётом текущего матча»: car_T_points = car_ss_pts + _as_int(stats.get("points")) car_T_assist = car_ss_ast + _as_int(stats.get("assist")) - car_T_block = car_ss_blk + _as_int(stats.get("block")) - car_T_dreb = car_ss_dreb + _as_int(stats.get("defReb")) - car_T_oreb = car_ss_oreb + _as_int(stats.get("offReb")) - car_T_reb = car_ss_reb + (_as_int(stats.get("defReb")) + _as_int(stats.get("offReb"))) - car_T_steal = car_ss_stl + _as_int(stats.get("steal")) - car_T_turn = car_ss_to + _as_int(stats.get("turnover")) - car_T_foul = car_ss_foul + _as_int(stats.get("foul")) - car_T_sec = car_ss_sec + _as_int(stats.get("second")) - car_T_gms = car_ss_gms + (1 if _as_int(stats.get("second")) > 0 else 0) - car_T_starts = car_ss_st + (1 if bool(stats.get("isStart")) else 0) + car_T_block = car_ss_blk + _as_int(stats.get("block")) + car_T_dreb = car_ss_dreb + _as_int(stats.get("defReb")) + car_T_oreb = car_ss_oreb + _as_int(stats.get("offReb")) + car_T_reb = car_ss_reb + ( + _as_int(stats.get("defReb")) + _as_int(stats.get("offReb")) + ) + car_T_steal = car_ss_stl + _as_int(stats.get("steal")) + car_T_turn = car_ss_to + _as_int(stats.get("turnover")) + car_T_foul = car_ss_foul + _as_int(stats.get("foul")) + car_T_sec = car_ss_sec + _as_int(stats.get("second")) + car_T_gms = car_ss_gms + (1 if _as_int(stats.get("second")) > 0 else 0) + car_T_starts = car_ss_st + (1 if bool(stats.get("isStart")) else 0) car_T_g1 = car_ss_g1 + g1 car_T_s1 = car_ss_s1 + s1 @@ -1614,9 +1989,9 @@ async def team(who: str): # Для «23» используем сумму 2-х и 3-х T_g23 = T_g2 + T_g3 - T_s23 = T_s2 + T_s3 + T_s23 = T_s2 + T_s3 car_T_g23 = car_T_g2 + car_T_g3 - car_T_s23 = car_T_s2 + car_T_s3 + car_T_s23 = car_T_s2 + car_T_s3 # print(avg_season, total_season) row = { "id": item.get("personId") or "", @@ -1641,8 +2016,8 @@ async def team(who: str): and item.get("lastName") is not None else "Команда" ), - "Name1GFX": (item.get('firstName') or '').strip(), - "Name2GFX": (item.get('lastName') or '').strip(), + "Name1GFX": (item.get("firstName") or "").strip(), + "Name2GFX": (item.get("lastName") or "").strip(), "captain": item.get("isCapitan", False), "age": item.get("age") or 0, "height": f"{item.get('height')} cm" if item.get("height") else 0, @@ -1678,121 +2053,122 @@ async def team(who: str): if item.get("startRole") == "Player" else "" ), - # live-стата - "pts": _as_int(stats.get("points")), - "pt-2": f"{g2}/{s2}", - "pt-3": f"{g3}/{s3}", - "pt-1": f"{g1}/{s1}", - "fg": f"{g2+g3}/{s2+s3}", - "ast": _as_int(stats.get("assist")), - "stl": _as_int(stats.get("steal")), - "blk": _as_int(stats.get("block")), - "blkVic": _as_int(stats.get("blocked")), - "dreb": _as_int(stats.get("defReb")), - "oreb": _as_int(stats.get("offReb")), - "reb": _as_int(stats.get("defReb")) + _as_int(stats.get("offReb")), - "to": _as_int(stats.get("turnover")), - "foul": _as_int(stats.get("foul")), - "foulT": _as_int(stats.get("foulT")), - "foulD": _as_int(stats.get("foulD")), - "foulC": _as_int(stats.get("foulC")), - "foulB": _as_int(stats.get("foulB")), - "fouled": _as_int(stats.get("foulsOn")), - "plusMinus": _as_int(stats.get("plusMinus")), - "dunk": _as_int(stats.get("dunk")), - "kpi": ( - _as_int(stats.get("points")) - + _as_int(stats.get("defReb")) + _as_int(stats.get("offReb")) - + _as_int(stats.get("assist")) + _as_int(stats.get("steal")) + _as_int(stats.get("block")) - + _as_int(stats.get("foulsOn")) - + (g1 - s1) + (g2 - s2) + (g3 - s3) - - _as_int(stats.get("turnover")) - _as_int(stats.get("foul")) - ), - "time": format_time(_as_int(stats.get("second"))), - - # сезон — средние (из последнего Avg) - "AvgPoints": season_avg.get("points") or "0.0", - "AvgAssist": season_avg.get("assist") or "0.0", - "AvgBlocks": season_avg.get("blockShot") or "0.0", - "AvgDefRebound": season_avg.get("defRebound") or "0.0", - "AvgOffRebound": season_avg.get("offRebound") or "0.0", - "AvgRebound": season_avg.get("rebound") or "0.0", - "AvgSteal": season_avg.get("steal") or "0.0", - "AvgTurnover": season_avg.get("turnover") or "0.0", - "AvgFoul": season_avg.get("foul") or "0.0", - "AvgOpponentFoul": season_avg.get("foulsOnPlayer") or "0.0", - "AvgDunk": season_avg.get("dunk") or "0.0", - "AvgPlayedTime": season_avg.get("playedTime") or "0:00", - "Shot1Percent": season_avg.get("shot1Percent") or "0.0%", - "Shot2Percent": season_avg.get("shot2Percent") or "0.0%", - "Shot3Percent": season_avg.get("shot3Percent") or "0.0%", - "Shot23Percent": season_avg.get("shot23Percent") or "0.0%", - - # сезон — Totals (суммы из Sum + live) - "TPoints": T_points, - "TShots1": f"{T_g1}/{T_s1}", - "TShots2": f"{T_g2}/{T_s2}", - "TShots3": f"{T_g3}/{T_s3}", - "TShots23": f"{T_g23}/{T_s23}", - "TShot1Percent": _pct(T_g1, T_s1), - "TShot2Percent": _pct(T_g2, T_s2), - "TShot3Percent": _pct(T_g3, T_s3), - "TShot23Percent": _pct(T_g23, T_s23), - "TAssist": T_assist, - "TBlocks": T_block, - "TDefRebound": T_dreb, - "TOffRebound": T_oreb, - "TRebound": T_reb, - "TSteal": T_steal, - "TTurnover": T_turn, - "TFoul": T_foul, - "TPlayedTime": format_time(T_sec), - "TGameCount": T_gms, - "TStartCount": T_starts, - - # карьера — средние (из последнего Avg) - "Career_AvgPoints": career_avg.get("points") or "0.0", - "Career_AvgAssist": career_avg.get("assist") or "0.0", - "Career_AvgBlocks": career_avg.get("blockShot") or "0.0", - "Career_AvgDefRebound": career_avg.get("defRebound") or "0.0", - "Career_AvgOffRebound": career_avg.get("offRebound") or "0.0", - "Career_AvgRebound": career_avg.get("rebound") or "0.0", - "Career_AvgSteal": career_avg.get("steal") or "0.0", - "Career_AvgTurnover": career_avg.get("turnover") or "0.0", - "Career_AvgFoul": career_avg.get("foul") or "0.0", - "Career_AvgOpponentFoul": career_avg.get("foulsOnPlayer") or "0.0", - "Career_AvgDunk": career_avg.get("dunk") or "0.0", - "Career_AvgPlayedTime": career_avg.get("playedTime") or "0:00", - "Career_Shot1Percent": career_avg.get("shot1Percent") or "0.0%", - "Career_Shot2Percent": career_avg.get("shot2Percent") or "0.0%", - "Career_Shot3Percent": career_avg.get("shot3Percent") or "0.0%", - "Career_Shot23Percent": career_avg.get("shot23Percent") or "0.0%", - - # карьера — Totals (суммы из Sum + live) - "Career_TPoints": car_T_points, - "Career_TShots1": f"{car_T_g1}/{car_T_s1}", - "Career_TShots2": f"{car_T_g2}/{car_T_s2}", - "Career_TShots3": f"{car_T_g3}/{car_T_s3}", - "Career_TShots23": f"{car_T_g23}/{car_T_s23}", - "Career_TShot1Percent": _pct(car_T_g1, car_T_s1), - "Career_TShot2Percent": _pct(car_T_g2, car_T_s2), - "Career_TShot3Percent": _pct(car_T_g3, car_T_s3), - "Career_TShot23Percent": _pct(car_T_g23, car_T_s23), - "Career_TAssist": car_T_assist, - "Career_TBlocks": car_T_block, - "Career_TDefRebound": car_T_dreb, - "Career_TOffRebound": car_T_oreb, - "Career_TRebound": car_T_reb, - "Career_TSteal": car_T_steal, - "Career_TTurnover": car_T_turn, - "Career_TFoul": car_T_foul, - "Career_TPlayedTime": format_time(car_T_sec), - "Career_TGameCount": car_T_gms, - "Career_TStartCount": car_T_starts, - - } + # live-стата + "pts": _as_int(stats.get("points")), + "pt-2": f"{g2}/{s2}", + "pt-3": f"{g3}/{s3}", + "pt-1": f"{g1}/{s1}", + "fg": f"{g2+g3}/{s2+s3}", + "ast": _as_int(stats.get("assist")), + "stl": _as_int(stats.get("steal")), + "blk": _as_int(stats.get("block")), + "blkVic": _as_int(stats.get("blocked")), + "dreb": _as_int(stats.get("defReb")), + "oreb": _as_int(stats.get("offReb")), + "reb": _as_int(stats.get("defReb")) + _as_int(stats.get("offReb")), + "to": _as_int(stats.get("turnover")), + "foul": _as_int(stats.get("foul")), + "foulT": _as_int(stats.get("foulT")), + "foulD": _as_int(stats.get("foulD")), + "foulC": _as_int(stats.get("foulC")), + "foulB": _as_int(stats.get("foulB")), + "fouled": _as_int(stats.get("foulsOn")), + "plusMinus": _as_int(stats.get("plusMinus")), + "dunk": _as_int(stats.get("dunk")), + "kpi": ( + _as_int(stats.get("points")) + + _as_int(stats.get("defReb")) + + _as_int(stats.get("offReb")) + + _as_int(stats.get("assist")) + + _as_int(stats.get("steal")) + + _as_int(stats.get("block")) + + _as_int(stats.get("foulsOn")) + + (g1 - s1) + + (g2 - s2) + + (g3 - s3) + - _as_int(stats.get("turnover")) + - _as_int(stats.get("foul")) + ), + "time": format_time(_as_int(stats.get("second"))), + # сезон — средние (из последнего Avg) + "AvgPoints": season_avg.get("points") or "0.0", + "AvgAssist": season_avg.get("assist") or "0.0", + "AvgBlocks": season_avg.get("blockShot") or "0.0", + "AvgDefRebound": season_avg.get("defRebound") or "0.0", + "AvgOffRebound": season_avg.get("offRebound") or "0.0", + "AvgRebound": season_avg.get("rebound") or "0.0", + "AvgSteal": season_avg.get("steal") or "0.0", + "AvgTurnover": season_avg.get("turnover") or "0.0", + "AvgFoul": season_avg.get("foul") or "0.0", + "AvgOpponentFoul": season_avg.get("foulsOnPlayer") or "0.0", + "AvgDunk": season_avg.get("dunk") or "0.0", + "AvgPlayedTime": season_avg.get("playedTime") or "0:00", + "Shot1Percent": season_avg.get("shot1Percent") or "0.0%", + "Shot2Percent": season_avg.get("shot2Percent") or "0.0%", + "Shot3Percent": season_avg.get("shot3Percent") or "0.0%", + "Shot23Percent": season_avg.get("shot23Percent") or "0.0%", + # сезон — Totals (суммы из Sum + live) + "TPoints": T_points, + "TShots1": f"{T_g1}/{T_s1}", + "TShots2": f"{T_g2}/{T_s2}", + "TShots3": f"{T_g3}/{T_s3}", + "TShots23": f"{T_g23}/{T_s23}", + "TShot1Percent": _pct(T_g1, T_s1), + "TShot2Percent": _pct(T_g2, T_s2), + "TShot3Percent": _pct(T_g3, T_s3), + "TShot23Percent": _pct(T_g23, T_s23), + "TAssist": T_assist, + "TBlocks": T_block, + "TDefRebound": T_dreb, + "TOffRebound": T_oreb, + "TRebound": T_reb, + "TSteal": T_steal, + "TTurnover": T_turn, + "TFoul": T_foul, + "TPlayedTime": format_time(T_sec), + "TGameCount": T_gms, + "TStartCount": T_starts, + # карьера — средние (из последнего Avg) + "Career_AvgPoints": career_avg.get("points") or "0.0", + "Career_AvgAssist": career_avg.get("assist") or "0.0", + "Career_AvgBlocks": career_avg.get("blockShot") or "0.0", + "Career_AvgDefRebound": career_avg.get("defRebound") or "0.0", + "Career_AvgOffRebound": career_avg.get("offRebound") or "0.0", + "Career_AvgRebound": career_avg.get("rebound") or "0.0", + "Career_AvgSteal": career_avg.get("steal") or "0.0", + "Career_AvgTurnover": career_avg.get("turnover") or "0.0", + "Career_AvgFoul": career_avg.get("foul") or "0.0", + "Career_AvgOpponentFoul": career_avg.get("foulsOnPlayer") or "0.0", + "Career_AvgDunk": career_avg.get("dunk") or "0.0", + "Career_AvgPlayedTime": career_avg.get("playedTime") or "0:00", + "Career_Shot1Percent": career_avg.get("shot1Percent") or "0.0%", + "Career_Shot2Percent": career_avg.get("shot2Percent") or "0.0%", + "Career_Shot3Percent": career_avg.get("shot3Percent") or "0.0%", + "Career_Shot23Percent": career_avg.get("shot23Percent") or "0.0%", + # карьера — Totals (суммы из Sum + live) + "Career_TPoints": car_T_points, + "Career_TShots1": f"{car_T_g1}/{car_T_s1}", + "Career_TShots2": f"{car_T_g2}/{car_T_s2}", + "Career_TShots3": f"{car_T_g3}/{car_T_s3}", + "Career_TShots23": f"{car_T_g23}/{car_T_s23}", + "Career_TShot1Percent": _pct(car_T_g1, car_T_s1), + "Career_TShot2Percent": _pct(car_T_g2, car_T_s2), + "Career_TShot3Percent": _pct(car_T_g3, car_T_s3), + "Career_TShot23Percent": _pct(car_T_g23, car_T_s23), + "Career_TAssist": car_T_assist, + "Career_TBlocks": car_T_block, + "Career_TDefRebound": car_T_dreb, + "Career_TOffRebound": car_T_oreb, + "Career_TRebound": car_T_reb, + "Career_TSteal": car_T_steal, + "Career_TTurnover": car_T_turn, + "Career_TFoul": car_T_foul, + "Career_TPlayedTime": format_time(car_T_sec), + "Career_TGameCount": car_T_gms, + "Career_TStartCount": car_T_starts, + } team_rows.append(row) - + # добиваем до 12 строк, чтобы UI был ровный count_player = sum(1 for x in team_rows if x["startRole"] == "Player") if count_player < 12 and team_rows: @@ -1851,15 +2227,7 @@ async def team(who: str): async def started_team(data): - started_team = sorted( - ( - p - for p in data - if p.get("startRole") == "Player" and p.get("isOnCourt") is True - ), - key=lambda x: int(x.get("num") or 0), - ) - return started_team + return data def add_new_team_stat( @@ -2116,7 +2484,7 @@ async def team_stats(): "val2": val2, } ) - return result_json + return maybe_clear_for_vmix(result_json) @app.get("/referee") @@ -2168,7 +2536,7 @@ async def referee(): else len(desired_order) ), ) - return referees + return maybe_clear_for_vmix(referees) @app.get("/team_comparison") @@ -2242,7 +2610,7 @@ async def team_comparison(): ), } teams.append(temp_team) - return teams + return maybe_clear_for_vmix(teams) else: return [{"Данных о сравнении команд нет!"}] @@ -2313,7 +2681,7 @@ async def regular_standings(): df["plus_minus"] = tg_plus - tg_minus standings_payload = df.to_dict(orient="records") - return standings_payload + return maybe_clear_for_vmix(standings_payload) @app.get("/live_status") @@ -2324,7 +2692,9 @@ async def live_status(): if not ls: # live-status ещё не прилетел - return [{"foulsA": 0, "foulsB": 0}] + return maybe_clear_for_vmix( + [{"foulsA": 0, "foulsB": 0, "scoreA": 0, "scoreB": 0}] + ) raw = ls.get("data") @@ -2332,20 +2702,24 @@ async def live_status(): if isinstance(raw, dict): # иногда API кладёт всё прямо в root, иногда внутрь result if "result" in raw and isinstance(raw["result"], dict): - return [raw["result"]] + return maybe_clear_for_vmix([raw["result"]]) else: # отдадим как есть, но в списке, чтобы фронт не сломать - return [raw] + return maybe_clear_for_vmix([raw]) # 2) если это просто строка статуса ("ok" / "no-status" / "error") if isinstance(raw, str): - return [{"status": raw}] + return maybe_clear_for_vmix([{"status": raw}]) # fallback - return [{"foulsA": 0, "foulsB": 0}] + return maybe_clear_for_vmix( + [{"foulsA": 0, "foulsB": 0, "scoreA": 0, "scoreB": 0}] + ) else: # матч не идёт — как у тебя было - return [{"foulsA": 0, "foulsB": 0}] + return maybe_clear_for_vmix( + [{"foulsA": 0, "foulsB": 0, "scoreA": 0, "scoreB": 0}] + ) @app.get("/info") @@ -2353,6 +2727,8 @@ async def info(): data = latest_data["game"]["data"]["result"] team1_name = data["team1"]["name"] team2_name = data["team2"]["name"] + team1_name_short = data["team1"]["abcName"] + team2_name_short = data["team2"]["abcName"] team1_logo = data["team1"]["logo"] team2_logo = data["team2"]["logo"] arena = data["arena"]["name"] @@ -2369,25 +2745,241 @@ async def info(): except ValueError: full_format = date_obj.strftime("%A, %#d %B %Y") short_format = date_obj.strftime("%A, %#d %b") - - - return [ - { - "team1": team1_name, - "team2": team2_name, - "logo1": team1_logo, - "logo2": team2_logo, - "arena": arena, - "short_arena": arena_short, - "region": region, - "league": league, - "league_full": league_full, - "season": season, - "stadia": stadia, - "date1": str(full_format), - "date2": str(short_format), - } + + return maybe_clear_for_vmix( + [ + { + "team1": team1_name, + "team2": team2_name, + "team1_short": team1_name_short, + "team2_short": team2_name_short, + "logo1": team1_logo, + "logo2": team2_logo, + "arena": arena, + "short_arena": arena_short, + "region": region, + "league": league, + "league_full": league_full, + "season": season, + "stadia": stadia, + "date1": str(full_format), + "date2": str(short_format), + } + ] + ) + + +@app.get("/play_by_play") +async def play_by_play(): + data = latest_data["game"]["data"]["result"] + data_pbp = data["plays"] + + team1_name = data["team1"]["name"] + team2_name = data["team2"]["name"] + + team1_startnum = [ + i["startNum"] + for i in next( + (t for t in data["teams"] if t["teamNumber"] == 1), + None, + )["starts"] + if i["startRole"] == "Player" ] + team2_startnum = [ + i["startNum"] + for i in next( + (t for t in data["teams"] if t["teamNumber"] == 2), + None, + )["starts"] + if i["startRole"] == "Player" + ] + + # если вообще нет плей-бай-плея — просто отдаём пустой список + if not data_pbp: + return maybe_clear_for_vmix([]) + + df_data_pbp = pd.DataFrame(data_pbp[::-1]) + last_event = data_pbp[-1] + if "play" not in df_data_pbp: + return maybe_clear_for_vmix([]) + + if ( + "live-status" in latest_data + and latest_data["live-status"]["data"] != "Not Found" + ): + json_quarter = latest_data["live-status"]["data"]["result"]["period"] + json_second = latest_data["live-status"]["data"]["result"]["second"] + else: + json_quarter = last_event["period"] + json_second = 0 + + if "3x3" in LEAGUE: + df_data_pbp["play"].replace({2: 1, 3: 2}, inplace=True) + + df_goals = df_data_pbp.loc[df_data_pbp["play"].isin([1, 2, 3])].copy() + if df_goals.empty: + return maybe_clear_for_vmix([]) + + df_goals.loc[df_goals["startNum"].isin(team1_startnum), "score1"] = df_goals["play"] + df_goals.loc[df_goals["startNum"].isin(team2_startnum), "score2"] = df_goals["play"] + + df_goals["score_sum1"] = df_goals["score1"].fillna(0).cumsum() + df_goals["score_sum2"] = df_goals["score2"].fillna(0).cumsum() + df_goals["new_sec"] = df_goals["sec"].astype(str).str.slice(0, -1).astype(int) + df_goals["time_now"] = (600 if json_quarter < 5 else 300) - json_second + df_goals["quar"] = json_quarter - df_goals["period"] + + # без numpy: diff_time через маски pandas + same_quarter = df_goals["quar"] == 0 + other_quarter = ~same_quarter + + df_goals.loc[same_quarter, "diff_time"] = ( + df_goals.loc[same_quarter, "time_now"] - df_goals.loc[same_quarter, "new_sec"] + ) + + df_goals.loc[other_quarter, "diff_time"] = ( + 600 * df_goals.loc[other_quarter, "quar"] + - df_goals.loc[other_quarter, "new_sec"] + + df_goals.loc[other_quarter, "time_now"] + ) + + df_goals["diff_time"] = df_goals["diff_time"].astype(int) + + df_goals["diff_time_str"] = df_goals["diff_time"].apply( + lambda x: f"{x // 60}:{str(x % 60).zfill(2)}" + ) + df_goals["team"] = df_goals.apply( + lambda row: team1_name if not pd.isna(row["score1"]) else team2_name, + axis=1, + ) + df_goals["text_rus"] = df_goals.apply( + lambda row: ( + f"рывок {int(row['score_sum1'])}-{int(row['score_sum2'])}" + if not pd.isna(row["score1"]) + else f"рывок {int(row['score_sum2'])}-{int(row['score_sum1'])}" + ), + axis=1, + ) + df_goals["text_time_rus"] = df_goals.apply( + lambda row: ( + f"рывок {int(row['score_sum1'])}-{int(row['score_sum2'])} за {row['diff_time_str']}" + if not pd.isna(row["score1"]) + else f"рывок {int(row['score_sum2'])}-{int(row['score_sum1'])} за {row['diff_time_str']}" + ), + axis=1, + ) + df_goals["text"] = df_goals.apply( + lambda row: ( + f"{team1_name} {int(row['score_sum1'])}-{int(row['score_sum2'])} run" + if not pd.isna(row["score1"]) + else f"{team2_name} {int(row['score_sum2'])}-{int(row['score_sum1'])} run" + ), + axis=1, + ) + df_goals["text_time"] = df_goals.apply( + lambda row: ( + f"{team1_name} {int(row['score_sum1'])}-{int(row['score_sum2'])} run in last {row['diff_time_str']}" + if not pd.isna(row["score1"]) + else f"{team2_name} {int(row['score_sum2'])}-{int(row['score_sum1'])} run in last {row['diff_time_str']}" + ), + axis=1, + ) + + new_order = ["text", "text_time"] + [ + col for col in df_goals.columns if col not in ["text", "text_time"] + ] + df_goals = df_goals[new_order] + + for _ in ["children", "start", "stop", "hl", "sort", "startNum", "zone", "x", "y"]: + if _ in df_goals.columns: + del df_goals[_] + + # 👉 здесь избавляемся от NaN: только для score1/score2 + df_goals["score1"] = df_goals["score1"].fillna("") + df_goals["score2"] = df_goals["score2"].fillna("") + + # если хочешь вообще никаких NaN во всём JSON — можно так: + # df_goals = df_goals.fillna("") + # print(payload) + payload = df_goals.to_dict(orient="records") + return maybe_clear_for_vmix(payload) + + +def change_vmix_datasource_urls(xml_data, new_base_url: str) -> bytes: + """ + Ищет все и меняет внутри на new_base_url + endpoint. + """ + + # 1. Приводим вход к bytes + if isinstance(xml_data, (bytes, bytearray)): + raw_bytes = bytes(xml_data) + elif isinstance(xml_data, str): + raw_bytes = xml_data.encode("utf-8") + elif isinstance(xml_data, io.IOBase) or hasattr(xml_data, "read"): + # nasio.load_bio, скорее всего, возвращает BytesIO + raw_bytes = xml_data.read() + try: + xml_data.seek(0) + except Exception: + pass + else: + raise TypeError(f"Unsupported xml_data type: {type(xml_data)}") + + # 2. Декодируем + text = raw_bytes.decode("utf-8", errors="replace") + + # 3. Парсим XML + root = ET.fromstring(text) + + # 4. Меняем URL + for ds in root.findall(".//datasource[@friendlyName='JSON']"): + for inst in ds.findall(".//instance"): + url_tag = inst.find(".//state/xml/url") + if url_tag is not None and url_tag.text: + old_url = url_tag.text.strip() + + # аккуратно выделяем endpoint + # http://127.0.0.1:8000/team1 -> /team1 + after_scheme = old_url.split("://", 1)[-1] + after_host = ( + after_scheme.split("/", 1)[-1] if "/" in after_scheme else "" + ) + endpoint = "/" + after_host if after_host else "" + + new_url = new_base_url.rstrip("/") + endpoint + url_tag.text = new_url + + # 5. Сериализуем обратно в bytes + new_xml = ET.tostring(root, encoding="utf-8", method="xml") + return new_xml + + +@app.get("/vmix") +async def vmix_project(): + vmix_bio = nasio.load_bio( + user=SYNO_USERNAME, + password=SYNO_PASSWORD, + nas_ip=SYNO_URL, + nas_port="443", + path=SYNO_PATH_МVMIX, + ) + system_name = platform.system() + if system_name == "Windows": + pass + else: + # ❗ На Linux/Synology/Docker — заменяем URL + edited_vmix = change_vmix_datasource_urls( + vmix_bio, f"https://{MYHOST}.tvstart.ru" + ) + # 2. гарантируем, что это bytes + if isinstance(edited_vmix, str): + edited_vmix = edited_vmix.encode("utf-8") + + return StreamingResponse( + io.BytesIO(edited_vmix), + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="VTB_{MYHOST}.vmix"'}, + ) if __name__ == "__main__":