diff --git a/get_data.py b/get_data.py index 62f0b09..00c8792 100644 --- a/get_data.py +++ b/get_data.py @@ -95,6 +95,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() @@ -137,6 +143,7 @@ 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 = { @@ -165,6 +172,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 +335,7 @@ def stop_live_threads(): ) else: logger.info("[threads] LIVE threads stopped") + CURRENT_THREADS_MODE = None # 👈 сбрасываем режим def stop_offline_threads(): @@ -311,6 +347,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") @@ -429,8 +466,17 @@ 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"], @@ -735,8 +781,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 +916,233 @@ 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) ...") + + # оставим только полезные ключи + 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() + 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(): + from datetime import time as dtime # для резервного парсинга времени global STATUS - # за сколько включать live - lead = timedelta(hours=1, minutes=10) - switch_at = game_dt - lead + 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"[reset] {now:%H:%M:%S} → T-1:15: сбрасываем предзагруженные данные") + try: + stop_offline_threads() # на всякий + 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}") + did_reset = True + + # Шаг 3: T-1:10 — включаем live-треды + if not did_live and now >= LIVE_AT: logger.info( - f"[prestart] it's {now}, game at {game_dt}, enabling LIVE threads (1h10m rule)" + f"[prestart] {now:%H:%M:%S}, игра в {game_dt:%H:%M}, включаем LIVE threads по правилу T-1:10" ) STATUS = "live_soon" - # сначала гасим оффлайн, если он крутится - stop_offline_threads() - # а потом включаем live + 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() @@ -986,10 +1224,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" @@ -1063,16 +1301,54 @@ async def top_team2(): return await top_sorted_team(data) -@app.get("/started_team1") -async def started_team1(): - data = await team("team1") - return await started_team(data) +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":"", } for _ in range(n)] + +@app.get("/started_team1") +async def started_team1(sort_by: str = None): + data = await team("team1") + 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 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 on_court[:5] if on_court else _placeholders(5) + + # дефолт — без фильтра, как раньше + return 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 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 on_court[:5] if on_court else _placeholders(5) + + return players @app.get("/game") @@ -1108,10 +1384,14 @@ 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 +1419,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 +1503,7 @@ async def status(request: Request):

League: {LEAGUE}

Team: {TEAM}

-

Game ID: {GAME_ID}

+

Game ID: {cached_game_id}{note}

Game Status: {gs_text}

@@ -1851,15 +2131,15 @@ 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 + # started_team = sorted( + # ( + # p + # for p in data + # if p.get("startRole") == "Player" and p.get("isStart") is True + # ), + # key=lambda x: int(x.get("num") or 0), + # ) + return data def add_new_team_stat( @@ -2353,6 +2633,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"] @@ -2375,6 +2657,8 @@ async def info(): { "team1": team1_name, "team2": team2_name, + "team1_short": team1_name_short, + "team2_short": team2_name_short, "logo1": team1_logo, "logo2": team2_logo, "arena": arena,