Compare commits

...

3 Commits

Author SHA1 Message Date
90df6ff765 1. добавил в gitigove .env
2. /vmix - генерируется проект vMix с нужными линками для виртуальных машин
2025-11-13 17:25:18 +03:00
0dab883b30 починил play_by_play для офлайн матча 2025-11-13 15:46:06 +03:00
ced3220d62 добавил новый endpoint /play-by-play 2025-11-13 14:42:24 +03:00
2 changed files with 283 additions and 116 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/__pycache__ /__pycache__
/TestJson /TestJson
/logs/* /logs/*
*.venv *.venv
*.env

View File

@@ -1,25 +1,20 @@
from fastapi import FastAPI from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response, HTMLResponse from fastapi.responses import Response, HTMLResponse, FileResponse, StreamingResponse
from fastapi import HTTPException from typing import Dict, Any
from fastapi import Request
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import requests import requests, uvicorn, json
import threading import threading, queue
import time
import queue
import argparse import argparse
import uvicorn
import os
import pandas as pd import pandas as pd
import json
from datetime import datetime, time as dtime, timedelta from datetime import datetime, time as dtime, timedelta
from fastapi.responses import Response from fastapi.responses import Response
import logging import logging
import logging.config import logging.config
import platform from dotenv import load_dotenv
import socket from pprint import pprint
import nasio
# передадим параметры через аргументы или глобальные переменные import io, os, platform, time
import xml.etree.ElementTree as ET
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@@ -29,15 +24,12 @@ parser.add_argument("--lang", default="en")
args = parser.parse_args() args = parser.parse_args()
MYHOST = platform.node() MYHOST = platform.node()
user_name = socket.gethostname()
if not os.path.exists("logs"): if not os.path.exists("logs"):
os.makedirs("logs") os.makedirs("logs")
telegram_bot_token = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY" telegram_bot_token = os.getenv("TELEGRAM_TOKEN")
# telegram_chat_id = 228977654 telegram_chat_id = os.getenv("TELEGRAM_CHAT_ID")
# telegram_chat_id = -4803699526
telegram_chat_id = -1003388354193
log_config = { log_config = {
"version": 1, "version": 1,
"handlers": { "handlers": {
@@ -68,7 +60,7 @@ log_config = {
"formatters": { "formatters": {
"telegram": { "telegram": {
"class": "telegram_handler.HtmlFormatter", "class": "telegram_handler.HtmlFormatter",
"format": f"%(levelname)s <b>[{MYHOST.upper()}] [{user_name}]</b>\n%(message)s", "format": f"%(levelname)s <b>[{MYHOST.upper()}]</b>\n%(message)s",
"use_emoji": "True", "use_emoji": "True",
}, },
"simple": { "simple": {
@@ -83,11 +75,19 @@ logging.config.dictConfig(log_config)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.handlers[2].formatter.use_emoji = True logger.handlers[2].formatter.use_emoji = True
pprint(f"Локальный файл окружения ={load_dotenv(verbose=True)}")
LEAGUE = args.league LEAGUE = args.league
TEAM = args.team TEAM = args.team
LANG = args.lang 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 STATUS = False
GAME_ID = None GAME_ID = None
SEASON = None SEASON = None
@@ -136,6 +136,7 @@ URLS = {
"play-by-play": "{host}/api/abc/games/play-by-play?id={game_id}", "play-by-play": "{host}/api/abc/games/play-by-play?id={game_id}",
} }
def maybe_clear_for_vmix(payload): def maybe_clear_for_vmix(payload):
""" """
Если включён режим очистки — возвращаем payload, Если включён режим очистки — возвращаем payload,
@@ -158,20 +159,6 @@ def start_offline_threads(season, game_id):
stop_live_threads() stop_live_threads()
stop_offline_threads() stop_offline_threads()
logger.info("[threads] switching to OFFLINE mode ...") logger.info("[threads] switching to OFFLINE mode ...")
# for key in latest_data:
# latest_data[key] = wipe_json_values(latest_data[key])
# 🔹 очищаем 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() stop_event_offline.clear()
@@ -619,10 +606,14 @@ def results_consumer():
and GAME_START_DT.date() == datetime.now().date() and GAME_START_DT.date() == datetime.now().date()
): ):
globals()["STATUS"] = "finished_wait" globals()["STATUS"] = "finished_wait"
globals()["CLEAR_OUTPUT_FOR_VMIX"] = True # 👈 включаем режим "пустых" данных globals()[
"CLEAR_OUTPUT_FOR_VMIX"
] = True # 👈 включаем режим "пустых" данных
else: else:
globals()["STATUS"] = "finished_wait" globals()["STATUS"] = "finished_wait"
globals()["CLEAR_OUTPUT_FOR_VMIX"] = True # 👈 включаем режим "пустых" данных globals()[
"CLEAR_OUTPUT_FOR_VMIX"
] = True # 👈 включаем режим "пустых" данных
human_time = datetime.fromtimestamp(switch_at).strftime( human_time = datetime.fromtimestamp(switch_at).strftime(
"%H:%M:%S" "%H:%M:%S"
@@ -642,7 +633,9 @@ def results_consumer():
"online" in raw_ls_status_low or "live" in raw_ls_status_low "online" in raw_ls_status_low or "live" in raw_ls_status_low
): ):
# если до этого стояла отложка — уберём # если до этого стояла отложка — уберём
globals()["CLEAR_OUTPUT_FOR_VMIX"] = False # 👈 выключаем очистку globals()[
"CLEAR_OUTPUT_FOR_VMIX"
] = False # 👈 выключаем очистку
if globals().get("OFFLINE_SWITCH_AT") is not None: if globals().get("OFFLINE_SWITCH_AT") is not None:
logger.info( logger.info(
"[status] match back to LIVE → cancel scheduled OFFLINE" "[status] match back to LIVE → cancel scheduled OFFLINE"
@@ -699,25 +692,7 @@ def results_consumer():
logger.debug( logger.debug(
"results_consumer: LIVE & partial game → keep previous one" "results_consumer: LIVE & partial game → keep previous one"
) )
# 2) Когда матч УЖЕ online (STATUS == 'live'):
# - поток 'game' в live-режиме погаснет сам (stop_when_live=True),
# но если вдруг что-то долетит, кладём только полный JSON.
continue 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: else:
latest_data[source] = { latest_data[source] = {
"ts": msg["ts"], "ts": msg["ts"],
@@ -1012,20 +987,6 @@ def start_offline_prevgame(season, prev_game_id: str):
logger.info("[threads] switching to OFFLINE mode (previous game) ...") 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]
# for key in latest_data:
# latest_data[key] = wipe_json_values(latest_data[key])
stop_event_offline.clear() stop_event_offline.clear()
threads_offline = [ threads_offline = [
threading.Thread( threading.Thread(
@@ -1215,7 +1176,9 @@ def start_prestart_watcher(game_dt: datetime | None):
f"[prestart] {now:%H:%M:%S}, игра в {game_dt:%H:%M}, включаем LIVE threads по правилу T-1:10" f"[prestart] {now:%H:%M:%S}, игра в {game_dt:%H:%M}, включаем LIVE threads по правилу T-1:10"
) )
STATUS = "live_soon" STATUS = "live_soon"
globals()["CLEAR_OUTPUT_FOR_VMIX"] = False # можно оставить пустоту до первых живых данных globals()[
"CLEAR_OUTPUT_FOR_VMIX"
] = False # можно оставить пустоту до первых живых данных
stop_offline_threads() # на всякий случай stop_offline_threads() # на всякий случай
start_live_threads(SEASON, GAME_ID) start_live_threads(SEASON, GAME_ID)
did_live = True did_live = True
@@ -1431,6 +1394,7 @@ def wipe_json_values(obj):
else: else:
return "" return ""
@app.get("/started_team1") @app.get("/started_team1")
async def started_team1(sort_by: str = None): async def started_team1(sort_by: str = None):
data = await team("team1") data = await team("team1")
@@ -1473,9 +1437,9 @@ async def started_team2(sort_by: str = None):
return maybe_clear_for_vmix(players) return maybe_clear_for_vmix(players)
@app.get("/game") @app.get("/latest_data")
async def game(): async def game():
return latest_data["game"] return latest_data
@app.get("/status") @app.get("/status")
@@ -1769,25 +1733,6 @@ def get_latest_game_safe(name: str):
return game 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]: def _pick_last_avg_and_sum(stats_list: list) -> tuple[dict, dict]:
"""Возвращает (season_sum, season_avg) из seasonStats. Безопасно при пустых данных.""" """Возвращает (season_sum, season_avg) из seasonStats. Безопасно при пустых данных."""
if not isinstance(stats_list, list) or len(stats_list) == 0: if not isinstance(stats_list, list) or len(stats_list) == 0:
@@ -2747,7 +2692,9 @@ async def live_status():
if not ls: if not ls:
# live-status ещё не прилетел # live-status ещё не прилетел
return maybe_clear_for_vmix([{"foulsA": 0, "foulsB": 0, "scoreA": 0, "scoreB": 0}]) return maybe_clear_for_vmix(
[{"foulsA": 0, "foulsB": 0, "scoreA": 0, "scoreB": 0}]
)
raw = ls.get("data") raw = ls.get("data")
@@ -2765,10 +2712,14 @@ async def live_status():
return maybe_clear_for_vmix([{"status": raw}]) return maybe_clear_for_vmix([{"status": raw}])
# fallback # fallback
return maybe_clear_for_vmix([{"foulsA": 0, "foulsB": 0, "scoreA": 0, "scoreB": 0}]) return maybe_clear_for_vmix(
[{"foulsA": 0, "foulsB": 0, "scoreA": 0, "scoreB": 0}]
)
else: else:
# матч не идёт — как у тебя было # матч не идёт — как у тебя было
return maybe_clear_for_vmix([{"foulsA": 0, "foulsB": 0, "scoreA": 0, "scoreB": 0}]) return maybe_clear_for_vmix(
[{"foulsA": 0, "foulsB": 0, "scoreA": 0, "scoreB": 0}]
)
@app.get("/info") @app.get("/info")
@@ -2795,25 +2746,240 @@ async def info():
full_format = date_obj.strftime("%A, %#d %B %Y") full_format = date_obj.strftime("%A, %#d %B %Y")
short_format = date_obj.strftime("%A, %#d %b") short_format = date_obj.strftime("%A, %#d %b")
return maybe_clear_for_vmix([ return maybe_clear_for_vmix(
{ [
"team1": team1_name, {
"team2": team2_name, "team1": team1_name,
"team1_short": team1_name_short, "team2": team2_name,
"team2_short": team2_name_short, "team1_short": team1_name_short,
"logo1": team1_logo, "team2_short": team2_name_short,
"logo2": team2_logo, "logo1": team1_logo,
"arena": arena, "logo2": team2_logo,
"short_arena": arena_short, "arena": arena,
"region": region, "short_arena": arena_short,
"league": league, "region": region,
"league_full": league_full, "league": league,
"season": season, "league_full": league_full,
"stadia": stadia, "season": season,
"date1": str(full_format), "stadia": stadia,
"date2": str(short_format), "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:
"""
Ищет все <datasource friendlyName="JSON"> и меняет <url> внутри на 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__": if __name__ == "__main__":