Compare commits

...

2 Commits

View File

@@ -95,6 +95,12 @@ GAME_TODAY = False # флаг: игра сегодня
GAME_SOON = False # флаг: игра сегодня и скоро (<1 часа) GAME_SOON = False # флаг: игра сегодня и скоро (<1 часа)
OFFLINE_SWITCH_AT = None # timestamp, когда надо уйти в оффлайн OFFLINE_SWITCH_AT = None # timestamp, когда надо уйти в оффлайн
OFFLINE_DELAY_SEC = 600 # 10 минут 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() results_q = queue.Queue()
@@ -137,6 +143,7 @@ def start_offline_threads(season, game_id):
return return
stop_live_threads() stop_live_threads()
stop_offline_threads()
logger.info("[threads] switching to OFFLINE mode ...") logger.info("[threads] switching to OFFLINE mode ...")
# 🔹 очищаем latest_data безопасно, чтобы не ломать структуру # 🔹 очищаем latest_data безопасно, чтобы не ломать структуру
keep_keys = { keep_keys = {
@@ -165,6 +172,34 @@ def start_offline_threads(season, game_id):
), ),
daemon=True, 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: for t in threads_offline:
t.start() t.start()
@@ -300,6 +335,7 @@ def stop_live_threads():
) )
else: else:
logger.info("[threads] LIVE threads stopped") logger.info("[threads] LIVE threads stopped")
CURRENT_THREADS_MODE = None # 👈 сбрасываем режим
def stop_offline_threads(): def stop_offline_threads():
@@ -311,6 +347,7 @@ def stop_offline_threads():
for t in threads_offline: for t in threads_offline:
t.join(timeout=1) t.join(timeout=1)
threads_offline = [] threads_offline = []
CURRENT_THREADS_MODE = None # 👈 сбрасываем режим
logger.info("[threads] OFFLINE threads stopped") logger.info("[threads] OFFLINE threads stopped")
@@ -429,8 +466,17 @@ def results_consumer():
incoming_status = payload.get("status") # может быть None incoming_status = payload.get("status") # может быть None
# print(source, incoming_status) # print(source, incoming_status)
if source == "game": if source == "game":
# обработка ТОЛЬКО в спец-ветке ниже # принимаем ТОЛЬКО тот game_id, который держим в PRELOAD_LOCK
pass 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: else:
latest_data[source] = { latest_data[source] = {
"ts": msg["ts"], "ts": msg["ts"],
@@ -735,8 +781,9 @@ def build_pretty_status_message():
Если game ещё нет — шлём хотя бы статусы источников. Если game ещё нет — шлём хотя бы статусы источников.
""" """
lines = [] lines = []
cgid = get_cached_game_id()
lines.append(f"🏀 <b>{LEAGUE.upper()}</b> • {TEAM}") lines.append(f"🏀 <b>{LEAGUE.upper()}</b> • {TEAM}")
lines.append(f"📌 Game ID: <code>{GAME_ID}</code>") lines.append(f"📌 Game ID: <code>{cgid or GAME_ID}</code>")
lines.append(f"🕒 {GAME_START_DT}") lines.append(f"🕒 {GAME_START_DT}")
# сначала попробуем собрать нормальный game # сначала попробуем собрать нормальный game
@@ -869,42 +916,233 @@ def status_broadcaster():
time.sleep(1) 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): 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: 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(): def _runner():
from datetime import time as dtime # для резервного парсинга времени
global STATUS 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(): while not stop_event.is_set():
now = datetime.now() now = datetime.now()
# если игра уже live/finished — не мешаем # если матч уже в другом конечном состоянии — выходим
if STATUS in ("live", "finished", "finished_wait", "finished_today"): if STATUS in ("live", "finished", "finished_wait", "finished_today"):
break break
# если время подошло — включаем live и выходим # Шаг 2: ровно T-1:15 — сбрасываем предзагруженные данные
if now >= switch_at: 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( 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" STATUS = "live_soon"
# сначала гасим оффлайн, если он крутится stop_offline_threads() # на всякий случай
stop_offline_threads()
# а потом включаем live
start_live_threads(SEASON, GAME_ID) start_live_threads(SEASON, GAME_ID)
did_live = True
break break
# иначе спим немного и проверяем снова time.sleep(15)
time.sleep(30) # можно 1560 сек
t = threading.Thread(target=_runner, daemon=True) t = threading.Thread(target=_runner, daemon=True)
t.start() t.start()
@@ -986,10 +1224,10 @@ async def lifespan(app: FastAPI):
start_live_threads(SEASON, GAME_ID) start_live_threads(SEASON, GAME_ID)
else: else:
STATUS = "today_not_started" STATUS = "today_not_started"
start_offline_threads(SEASON, GAME_ID) # start_offline_threads(SEASON, GAME_ID)
else: else:
STATUS = "today_not_started" STATUS = "today_not_started"
start_offline_threads(SEASON, GAME_ID) # start_offline_threads(SEASON, GAME_ID)
elif cal_status == "Online": elif cal_status == "Online":
STATUS = "live" STATUS = "live"
@@ -1063,16 +1301,54 @@ async def top_team2():
return await top_sorted_team(data) return await top_sorted_team(data)
@app.get("/started_team1") def _b(v) -> bool:
async def started_team1(): if isinstance(v, bool): return v
data = await team("team1") if isinstance(v, (int, float)): return v != 0
return await started_team(data) 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") @app.get("/started_team2")
async def started_team2(): async def started_team2(sort_by: str = None):
data = await team("team2") 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") @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( 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] [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' <span style="color:#ffb84d;">(предзагружены данные прошлой игры)</span>'
data = { data = {
"league": LEAGUE, "league": LEAGUE,
"team": TEAM, "team": TEAM,
"game_id": GAME_ID, "game_id": cached_game_id,
"game_status": STATUS, "game_status": STATUS,
"statuses": [ "statuses": [
{ {
@@ -1139,7 +1419,7 @@ async def status(request: Request):
league=LEAGUE, league=LEAGUE,
season=SEASON, season=SEASON,
lang=LANG, lang=LANG,
game_id=GAME_ID, game_id=cached_game_id,
), ),
"color": color_for_status( "color": color_for_status(
latest_data[item]["data"]["status"] latest_data[item]["data"]["status"]
@@ -1223,7 +1503,7 @@ async def status(request: Request):
<div class="header-info"> <div class="header-info">
<p><b>League:</b> {LEAGUE}</p> <p><b>League:</b> {LEAGUE}</p>
<p><b>Team:</b> {TEAM}</p> <p><b>Team:</b> {TEAM}</p>
<p><b>Game ID:</b> {GAME_ID}</p> <p><b>Game ID:</b> {cached_game_id}{note}</p>
<p><b>Game Status:</b> <span class="{gs_class}">{gs_text}</span></p> <p><b>Game Status:</b> <span class="{gs_class}">{gs_text}</span></p>
</div> </div>
@@ -1851,15 +2131,15 @@ async def team(who: str):
async def started_team(data): async def started_team(data):
started_team = sorted( # started_team = sorted(
( # (
p # p
for p in data # for p in data
if p.get("startRole") == "Player" and p.get("isOnCourt") is True # if p.get("startRole") == "Player" and p.get("isStart") is True
), # ),
key=lambda x: int(x.get("num") or 0), # key=lambda x: int(x.get("num") or 0),
) # )
return started_team return data
def add_new_team_stat( def add_new_team_stat(
@@ -2353,6 +2633,8 @@ async def info():
data = latest_data["game"]["data"]["result"] data = latest_data["game"]["data"]["result"]
team1_name = data["team1"]["name"] team1_name = data["team1"]["name"]
team2_name = data["team2"]["name"] team2_name = data["team2"]["name"]
team1_name_short = data["team1"]["abcName"]
team2_name_short = data["team2"]["abcName"]
team1_logo = data["team1"]["logo"] team1_logo = data["team1"]["logo"]
team2_logo = data["team2"]["logo"] team2_logo = data["team2"]["logo"]
arena = data["arena"]["name"] arena = data["arena"]["name"]
@@ -2375,6 +2657,8 @@ async def info():
{ {
"team1": team1_name, "team1": team1_name,
"team2": team2_name, "team2": team2_name,
"team1_short": team1_name_short,
"team2_short": team2_name_short,
"logo1": team1_logo, "logo1": team1_logo,
"logo2": team2_logo, "logo2": team2_logo,
"arena": arena, "arena": arena,