diff --git a/get_data.py b/get_data.py index 489fc8b..a086a3b 100644 --- a/get_data.py +++ b/get_data.py @@ -310,6 +310,18 @@ def stop_offline_threads(): logger.info("[threads] OFFLINE threads stopped") +def has_full_game_ready() -> bool: + game = latest_data.get("game") + if not game: + return False + payload = game.get("data", game) + return ( + isinstance(payload, dict) + and isinstance(payload.get("data"), dict) + and isinstance(payload["data"].get("result"), dict) + and "teams" in payload["data"]["result"] + ) + # Функция запускаемая в потоках def get_data_from_API( name: str, @@ -321,12 +333,10 @@ def get_data_from_API( 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 did_first_fetch: - logger.info( - f"{[{current_time}]} [{name}] stopping because STATUS='live' and first fetch done" - ) + 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 - start = time.time() + try: value = requests.get(url, timeout=5).json() did_first_fetch = True # помечаем, что один заход сделали @@ -370,11 +380,10 @@ 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 did_first_fetch: - logger.info( - f"[{name}] stopping during sleep because STATUS='live' and first fetch done" - ) + 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) slept += 1 # если запрос занял дольше — просто сразу следующую итерацию @@ -575,19 +584,19 @@ 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) + 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) + else: + logger.debug("results_consumer: LIVE & partial game → keep previous one") # 2) Когда матч УЖЕ online (STATUS == 'live'): # - поток 'game' в live-режиме погаснет сам (stop_when_live=True), # но если вдруг что-то долетит, кладём только полный JSON. - else: - if is_full: - latest_data["game"] = {"ts": msg["ts"], "data": payload} - logger.debug("results_consumer: LIVE & full game → replaced") - else: - # Ничего не трогаем, чтобы не затереть полноценные данные пустотой. - logger.debug("results_consumer: LIVE & partial game → keep previous one") continue # # game неполный # if not has_game_already: @@ -1010,7 +1019,7 @@ def format_time(seconds: float | int) -> str: @app.get("/team1") async def team1(): - game = get_latest_game_safe() + game = get_latest_game_safe("game") if not game: raise HTTPException(status_code=503, detail="game data not ready") return await team("team1") @@ -1018,7 +1027,7 @@ async def team1(): @app.get("/team2") async def team2(): - game = get_latest_game_safe() + game = get_latest_game_safe("game") if not game: raise HTTPException(status_code=503, detail="game data not ready") return await team("team2") @@ -1247,7 +1256,7 @@ async def status(request: Request): @app.get("/scores") async def scores(): - game = get_latest_game_safe() + game = get_latest_game_safe("game") if not game: # игры ещё нет или пришёл только частичный ответ # отдаём пустую структуру, чтобы фронт не падал @@ -1309,13 +1318,13 @@ async def top_sorted_team(data): return top_sorted_team -def get_latest_game_safe(): +def get_latest_game_safe(name:str): """ Безопасно достаём актуальный game из latest_data. Возвращаем None, если структура ещё не готова или прилетел "плохой" game (например, с {"status": "no-status"} без data/result). """ - game = latest_data.get("game") + game = latest_data.get(name) if not game: return None @@ -1338,33 +1347,118 @@ def get_latest_game_safe(): 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: + return {}, {} + # В JSON конец массива: ... {"class":"Sum"}, {"class":"Avg"} + 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 {} + + # Бывают инверсии порядка (на всякий случай): попробуем найти явно + 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: + season_avg = x.get("stats", {}) or {} + 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 + return season_sum, season_avg + + +def _pick_career_sum_and_avg(carrier_list: list) -> tuple[dict, dict]: + """Возвращает (career_sum, career_avg) из carrier. В API встречаются блоки с class: Normal/Sum/Avg.""" + if not isinstance(carrier_list, list) or len(carrier_list) == 0: + return {}, {} + career_sum, career_avg = {}, {} + # Ищем явные «Sum» и «Avg» + for x in reversed(carrier_list): + if isinstance(x, dict): + cls = str(x.get("class", "")).lower() + stats = x.get("stats", {}) or {} + if cls == "sum" and not career_sum: + career_sum = stats + elif cls == "avg" and not career_avg: + career_avg = stats + if career_sum and career_avg: + break + # Если «Avg» нет (часто для карьеры бывает только Normal/Sum) — ок, оставим пустым + return career_sum, career_avg + + +def _as_int(v, default=0): + try: + # в JSON часто строки; пустые строки -> 0 + if v in ("", None): + return default + return int(float(v)) + except Exception: + return default + + +def _safe(d: dict) -> dict: + return d if isinstance(d, dict) else {} + + async def team(who: str): """ Возвращает данные по команде (team1 / team2) из актуального game. Защищена от ситуации, когда latest_data["game"] ещё не прогрелся или в него прилетел "плохой" ответ от API. """ - game = get_latest_game_safe() + game = get_latest_game_safe("game") 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: + raise HTTPException(status_code=503, detail="pregame-full-stats data not ready") # нормализуем доступ к данным game_data = game["data"] if "data" in game else game + full_stat_data = full_stat["data"] if "data" in full_stat else full_stat result = game_data[ "result" ] # здесь уже безопасно, мы проверили в get_latest_game_safe - + result_full = full_stat_data["result"] + # в result ожидаем "teams" teams = result.get("teams") + if not teams: raise HTTPException(status_code=503, detail="game teams not ready") # выбираем команду if who == "team1": payload = next((t for t in teams if t.get("teamNumber") == 1), None) + payload_full = result_full.get("team1PlayersStats") else: payload = next((t for t in teams if t.get("teamNumber") == 2), None) + payload_full = result_full.get("team2PlayersStats") if payload is None: raise HTTPException(status_code=404, detail=f"{who} not found in game data") @@ -1383,10 +1477,126 @@ 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) + + 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 []) + # карьера + 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")) + + # Сезонные суммы из 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_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_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")) + + # Карьерные суммы из 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_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_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")) + + # 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_g1 = ss_g1 + g1 + T_s1 = ss_s1 + s1 + T_g2 = ss_g2 + g2 + 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_g1 = car_ss_g1 + g1 + car_T_s1 = car_ss_s1 + s1 + car_T_g2 = car_ss_g2 + g2 + car_T_s2 = car_ss_s2 + s2 + car_T_g3 = car_ss_g3 + g3 + car_T_s3 = car_ss_s3 + s3 + + # Проценты (без деления на 0) + def _pct(goal, shot): + return f"{round(goal*100/shot, 1)}%" if shot else "0.0%" + + # Для «23» используем сумму 2-х и 3-х + T_g23 = T_g2 + T_g3 + T_s23 = T_s2 + T_s3 + car_T_g23 = car_T_g2 + car_T_g3 + car_T_s23 = car_T_s2 + car_T_s3 + # print(avg_season, total_season) row = { "id": item.get("personId") or "", "num": item.get("displayNumber"), @@ -1434,69 +1644,121 @@ async def team(who: str): ) + ".svg" ), - "pts": stats.get("points", 0), - "pt-2": f"{stats.get('goal2',0)}/{stats.get('shot2',0)}" if stats else 0, - "pt-3": f"{stats.get('goal3',0)}/{stats.get('shot3',0)}" if stats else 0, - "pt-1": f"{stats.get('goal1',0)}/{stats.get('shot1',0)}" if stats else 0, - "fg": ( - f"{stats.get('goal2',0)+stats.get('goal3',0)}/" - f"{stats.get('shot2',0)+stats.get('shot3',0)}" - if stats - else 0 - ), - "ast": stats.get("assist", 0), - "stl": stats.get("steal", 0), - "blk": stats.get("block", 0), - "blkVic": stats.get("blocked", 0), - "dreb": stats.get("defReb", 0), - "oreb": stats.get("offReb", 0), - "reb": stats.get("defReb", 0) + stats.get("offReb", 0), - "to": stats.get("turnover", 0), - "foul": stats.get("foul", 0), - "foulT": stats.get("foulT", 0), - "foulD": stats.get("foulD", 0), - "foulC": stats.get("foulC", 0), - "foulB": stats.get("foulB", 0), - "fouled": stats.get("foulsOn", 0), - "plusMinus": stats.get("plusMinus", 0), - "dunk": stats.get("dunk", 0), - "kpi": ( - stats.get("points", 0) - + stats.get("defReb", 0) - + stats.get("offReb", 0) - + stats.get("assist", 0) - + stats.get("steal", 0) - + stats.get("block", 0) - + stats.get("foulsOn", 0) - + (stats.get("goal1", 0) - stats.get("shot1", 0)) - + (stats.get("goal2", 0) - stats.get("shot2", 0)) - + (stats.get("goal3", 0) - stats.get("shot3", 0)) - - stats.get("turnover", 0) - - stats.get("foul", 0) - ), - "time": format_time(stats.get("second", 0)), - "pts1q": 0, - "pts2q": 0, - "pts3q": 0, - "pts4q": 0, - "pts1h": 0, - "pts2h": 0, - "Name1GFX": (item.get("firstName") or "").strip(), - "Name2GFX": (item.get("lastName") or "").strip(), - "photoGFX": ( - os.path.join( - "D:\\Photos", - LEAGUE.lower(), - result[who]["name"], - f"{item.get('displayNumber')}.png", - ) - if item.get("startRole") == "Player" - else "" - ), - "isOnCourt": stats.get("isOnCourt", False), - } - team_rows.append(row) + # 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: