1. добавил карту бросков

2. /last_5_games: последние пять игр + 1 следующая игра. в последних пяти играх не учитывается игра, которая загруженна
This commit is contained in:
2025-11-21 16:28:29 +03:00
parent d080faac2f
commit 8c8ea3f9a9

View File

@@ -18,8 +18,16 @@ import xml.etree.ElementTree as ET
import re import re
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from io import BytesIO from io import BytesIO
import warnings
warnings.filterwarnings(
"ignore",
message="Data Validation extension is not supported and will be removed",
category=UserWarning,
module="openpyxl",
)
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--league", default="vtb") parser.add_argument("--league", default="vtb")
@@ -31,18 +39,20 @@ MYHOST = platform.node()
if not os.path.exists("logs"): if not os.path.exists("logs"):
os.makedirs("logs") os.makedirs("logs")
def get_fqdn(): def get_fqdn():
system_name = platform.system() system_name = platform.system()
if system_name == "Linux": if system_name == "Linux":
hostname = platform.node().lower() hostname = platform.node().lower()
fqdn = f"https://{hostname}.tvstart.ru" fqdn = f"https://{hostname}.tvstart.ru"
else: else:
fqdn = "http://127.0.0.1:8000" fqdn = "http://127.0.0.1:8000"
return fqdn return fqdn
FQDN = get_fqdn() FQDN = get_fqdn()
telegram_bot_token = os.getenv("TELEGRAM_TOKEN") telegram_bot_token = os.getenv("TELEGRAM_TOKEN")
@@ -92,7 +102,7 @@ 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)}") pprint(f"Локальный файл окружения = {load_dotenv(verbose=True)}")
LEAGUE = args.league LEAGUE = args.league
TEAM = args.team TEAM = args.team
@@ -139,21 +149,7 @@ if isinstance(_syno_goal_raw, BytesIO):
_syno_goal_raw = _syno_goal_raw.getvalue() _syno_goal_raw = _syno_goal_raw.getvalue()
SYNO_GOAL = _syno_goal_raw # bytes или None SYNO_GOAL = _syno_goal_raw # bytes или None
SHOTMAP_SUBDIR = "shotmaps"
SHOTMAP_DIR = os.path.join(os.getcwd(), "shotmaps")
os.makedirs(SHOTMAP_DIR, exist_ok=True)
try:
for name in os.listdir(SHOTMAP_DIR):
fp = os.path.join(SHOTMAP_DIR, name)
if os.path.isfile(fp):
os.remove(fp)
logger.info(f"[shotmap] очищена папка {SHOTMAP_DIR} при старте")
except Exception as e:
logger.warning(f"[shotmap] не удалось очистить {SHOTMAP_DIR}: {e}")
CALENDAR = None CALENDAR = None
STATUS = False STATUS = False
GAME_ID = None GAME_ID = None
SEASON = None SEASON = None
@@ -168,9 +164,6 @@ PRELOAD_LOCK = False # когда True — consumer будет принимат
PRELOADED_GAME_ID = None # ID матча, который мы держим «тёплым» PRELOADED_GAME_ID = None # ID матча, который мы держим «тёплым»
PRELOAD_HOLD_UNTIL = None # timestamp, до какого момента держим (T-1:15) PRELOAD_HOLD_UNTIL = None # timestamp, до какого момента держим (T-1:15)
# 🔥 кэш картинок в оперативной памяти
SHOTMAP_CACHE: dict[str, bytes] = {}
# общая очередь # общая очередь
results_q = queue.Queue() results_q = queue.Queue()
# тут будем хранить последние данные # тут будем хранить последние данные
@@ -191,6 +184,13 @@ CURRENT_THREADS_MODE = None
CLEAR_OUTPUT_FOR_VMIX = False CLEAR_OUTPUT_FOR_VMIX = False
EMPTY_PHOTO_PATH = r"D:\Графика\БАСКЕТБОЛ\VTB League\ASSETS\EMPTY.png" EMPTY_PHOTO_PATH = r"D:\Графика\БАСКЕТБОЛ\VTB League\ASSETS\EMPTY.png"
# 🔥 кэш картинок в оперативной памяти
SHOTMAP_CACHE: dict[str, bytes] = {}
# новое хранилище shotmaps по startNum
SHOTMAPS: dict[int, dict] = {}
SHOTMAPS_LOCK = threading.Lock()
URLS = { URLS = {
"seasons": "{host}api/abc/comps/seasons?Tag={league}", "seasons": "{host}api/abc/comps/seasons?Tag={league}",
@@ -1368,6 +1368,13 @@ async def lifespan(app: FastAPI):
) )
thread_status_broadcaster.start() thread_status_broadcaster.start()
# новый поток для shotmaps
thread_shotmap = threading.Thread(
target=shotmap_worker,
daemon=True,
)
thread_shotmap.start()
# 5. решаем, что запускать # 5. решаем, что запускать
if not is_today: if not is_today:
STATUS = "no_game_today" STATUS = "no_game_today"
@@ -1414,6 +1421,7 @@ async def lifespan(app: FastAPI):
thread_result_consumer.join(timeout=1) thread_result_consumer.join(timeout=1)
thread_status_broadcaster.join(timeout=1) thread_status_broadcaster.join(timeout=1)
thread_excel.join(timeout=1) thread_excel.join(timeout=1)
thread_shotmap.join(timeout=1)
app = FastAPI( app = FastAPI(
@@ -1423,7 +1431,7 @@ app = FastAPI(
openapi_url=None, # ❌ отключает /openapi.json openapi_url=None, # ❌ отключает /openapi.json
) )
# раздаём /shotmaps как статику из SHOTMAP_DIR # раздаём /shotmaps как статику из SHOTMAP_DIR
app.mount("/shotmaps", StaticFiles(directory=SHOTMAP_DIR), name="shotmaps") # app.mount("/shotmaps", StaticFiles(directory=SHOTMAP_DIR), name="shotmaps")
# @app.get("/shotmaps/{filename}") # @app.get("/shotmaps/{filename}")
# async def get_shotmap(filename: str): # async def get_shotmap(filename: str):
@@ -1433,6 +1441,7 @@ app.mount("/shotmaps", StaticFiles(directory=SHOTMAP_DIR), name="shotmaps")
# raise HTTPException(status_code=404, detail="Shotmap not found") # raise HTTPException(status_code=404, detail="Shotmap not found")
# return Response(content=data, media_type="image/png") # return Response(content=data, media_type="image/png")
def format_time(seconds: float | int) -> str: def format_time(seconds: float | int) -> str:
""" """
Удобный формат времени для игроков: Удобный формат времени для игроков:
@@ -2311,6 +2320,7 @@ async def team(who: str):
"Career_TStartCount": car_T_starts, "Career_TStartCount": car_T_starts,
"startNum": stats.get("startNum"), "startNum": stats.get("startNum"),
"photoShotMapGFX": "", "photoShotMapGFX": "",
"mask": "#FFFFFF",
} }
team_rows.append(row) team_rows.append(row)
@@ -2350,6 +2360,8 @@ async def team(who: str):
"kpi", "kpi",
]: ]:
empty_row[key] = 0 empty_row[key] = 0
elif key == "mask":
empty_row[key] = "#FFFFFF00"
else: else:
empty_row[key] = "" empty_row[key] = ""
team_rows.append(empty_row) team_rows.append(empty_row)
@@ -2401,26 +2413,19 @@ async def team(who: str):
is_made = play_code in (2, 3) # 2,3 — точные, 5,6 — промахи is_made = play_code in (2, 3) # 2,3 — точные, 5,6 — промахи
shots_by_startnum.setdefault(sn, []).append((x, y, is_made, sec, period)) shots_by_startnum.setdefault(sn, []).append((x, y, is_made, sec, period))
# Рендерим по картинке на каждого startNum # --- shotmaps: используем уже готовые пути из глобального SHOTMAPS ---
shotmap_paths: dict[int, str] = {} with SHOTMAPS_LOCK:
for sn, points in shots_by_startnum.items(): shotmaps_snapshot = dict(SHOTMAPS)
# timestamp/версия — просто количество бросков этого игрока.
# Увеличилось кол-во бросков → поменялся путь → vMix не возьмёт картинку из кеша.
version = str(len(points))
bib = str(sn) # можно заменить на номер игрока, если нужно
path = get_image(points, bib, version)
shotmap_paths[sn] = path
# Присоединяем пути к игрокам по startNum
for row in sorted_team: for row in sorted_team:
sn = row.get("startNum") sn = row.get("startNum")
if sn in shotmap_paths: info = shotmaps_snapshot.get(sn)
row["photoShotMapGFX"] = f"{shotmap_paths[sn]}" if info and info.get("count", 0) > 0:
# сюда кладём путь, который воркер получил из get_image:
# например: "https://host/image/23_shots_7"
row["photoShotMapGFX"] = FQDN + info.get("url", "")
else: else:
# если бросков не было — оставляем пустую строку row["photoShotMapGFX"] = EMPTY_PHOTO_PATH
row["photoShotMapGFX"] = ""
return sorted_team return sorted_team
@@ -3377,11 +3382,11 @@ def get_image(points, bib, count_point):
x, y — координаты из API, где (0,0) = центр кольца x, y — координаты из API, где (0,0) = центр кольца
is_made — True (play 2,3) или False (play 5,6) is_made — True (play 2,3) или False (play 5,6)
bib: startNum/номер игрока bib: startNum/номер игрока
count_point: строка-версия (анти-кэш vMix) count_point: строка-версия (количество бросков)
""" """
if not points: if not points:
return "" return b""
base_image = Image.new("RGBA", (1500, 2800), (0, 0, 0, 0)) base_image = Image.new("RGBA", (1500, 2800), (0, 0, 0, 0))
width, height = base_image.size width, height = base_image.size
@@ -3404,19 +3409,19 @@ def get_image(points, bib, count_point):
point_radius = 30 point_radius = 30
# --- шрифт --- # # --- шрифт ---
font = ImageFont.load_default() # font = ImageFont.load_default()
try: # try:
nasio_font = SYNO_FONT_PATH # nasio_font = SYNO_FONT_PATH
if nasio_font: # if nasio_font:
if isinstance(nasio_font, BytesIO): # if isinstance(nasio_font, BytesIO):
nasio_font = nasio_font.getvalue() # nasio_font = nasio_font.getvalue()
if isinstance(nasio_font, (bytes, bytearray)): # if isinstance(nasio_font, (bytes, bytearray)):
font = ImageFont.truetype(BytesIO(nasio_font), 18) # font = ImageFont.truetype(BytesIO(nasio_font), 18)
else: # else:
font = ImageFont.truetype(nasio_font, 18) # font = ImageFont.truetype(nasio_font, 18)
except Exception as e: # except Exception as e:
logger.warning(f"[shotmap] не удалось загрузить шрифт: {e}") # logger.warning(f"[shotmap] не удалось загрузить шрифт: {e}")
for x_raw, y_raw, is_made, sec, period in points: for x_raw, y_raw, is_made, sec, period in points:
try: try:
@@ -3463,33 +3468,27 @@ def get_image(points, bib, count_point):
color = (0, 255, 0, 255) if is_made else (255, 0, 0, 255) color = (0, 255, 0, 255) if is_made else (255, 0, 0, 255)
draw.ellipse(bbox, fill=color) draw.ellipse(bbox, fill=color)
# --- подпись Q{period} по центру --- # # --- подпись Q{period} по центру ---
label = f"Q{period}" # label = f"Q{period}"
# узнаём размер текста (Pillow 10+ — textbbox, старые — textsize) # try:
try: # bbox = draw.textbbox((0, 0), label, font=font)
bbox = draw.textbbox((0, 0), label, font=font) # text_w = bbox[2] - bbox[0]
text_w = bbox[2] - bbox[0] # text_h = bbox[3] - bbox[1]
text_h = bbox[3] - bbox[1] # except AttributeError:
except AttributeError: # text_w, text_h = draw.textsize(label, font=font)
# fallback для старых версий Pillow
text_w, text_h = draw.textsize(label, font=font)
# центр иконки = центр текста # center_x, center_y = px, py
center_x, center_y = px, py # text_x = center_x - text_w // 2
text_x = center_x - text_w // 2 # text_y = center_y - text_h // 2
text_y = center_y - text_h // 2
# тень # draw.text((text_x + 1, text_y + 1), label, font=font, fill=(255, 255, 255, 255))
draw.text((text_x + 1, text_y + 1), label, font=font, fill=(0, 0, 0, 255)) # draw.text((text_x, text_y), label, font=font, fill=(0, 0, 0, 255))
# текст
draw.text((text_x, text_y), label, font=font, fill=(255, 255, 255, 255))
# --- сохраняем картинку в оперативную память ---
filename = f"{bib}_shots_{count_point}" filename = f"{bib}_shots_{count_point}"
buf = BytesIO() buf = BytesIO()
try: try:
# compress_level=1 — быстрее, чем дефолт base_image = base_image.transpose(Image.ROTATE_90)
base_image.save(buf, format="PNG", compress_level=1) base_image.save(buf, format="PNG", compress_level=1)
except Exception as e: except Exception as e:
logger.warning(f"[shotmap] не удалось сохранить shotmap в память: {e}") logger.warning(f"[shotmap] не удалось сохранить shotmap в память: {e}")
@@ -3498,15 +3497,96 @@ def get_image(points, bib, count_point):
data = buf.getvalue() data = buf.getvalue()
SHOTMAP_CACHE[filename] = data # кладём в RAM SHOTMAP_CACHE[filename] = data # кладём в RAM
# формируем URL для vMix # относительный путь для HTTP-эндпоинта
public_url = f"{FQDN}/image/{filename}" public_url = f"/image/{filename}"
# logger.info(
# f"[shotmap] generated in-memory shotmap for bib={bib}, ver={count_point} "
# f"-> {filename}, url={public_url}"
# )
return public_url return public_url
def shotmap_worker():
"""
Фоновый поток: следит за latest_data['game'] и пересчитывает карты бросков
по каждому startNum. Картинки лежат только в RAM (SHOTMAP_CACHE/SHOTMAPS).
"""
last_counts: dict[int, int] = {}
while not stop_event.is_set():
try:
game = get_latest_game_safe("game")
if not game:
time.sleep(1)
continue
# в get_latest_game_safe уже нормализована структура,
# но на всякий случай ещё раз берём data/result
game_data = game.get("data") if isinstance(game, dict) else None
if not isinstance(game_data, dict):
time.sleep(1)
continue
result = game_data.get("result") or {}
plays = result.get("plays") or []
# собираем все броски по startNum
shots_by_startnum: dict[int, list[tuple]] = {}
for ev in plays:
play_code = ev.get("play")
# 2,3 — попали; 5,6 — промахи
if play_code not in (2, 3, 5, 6):
continue
sn = ev.get("startNum")
if sn is None:
continue
x = ev.get("x")
y = ev.get("y")
if x is None or y is None:
continue
sec = ev.get("sec")
period = ev.get("period")
is_made = play_code in (2, 3)
shots_by_startnum.setdefault(sn, []).append(
(x, y, is_made, sec, period)
)
# обновляем только тех, у кого поменялось количество бросков
for sn, points in shots_by_startnum.items():
count = len(points)
if count == 0:
continue
if last_counts.get(sn) == count:
# количество бросков и так то же — картинка уже есть
continue
version = str(count)
bib = str(sn)
# get_image:
# - рисует shotmap
# - кладёт PNG bytes в SHOTMAP_CACHE[filename]
# - возвращает полный URL вида f"{FQDN}/image/{filename}"
url = get_image(points, bib, version)
if not url:
continue
with SHOTMAPS_LOCK:
SHOTMAPS[sn] = {
"count": count,
"url": url, # готовый путь, который можно отдавать в vMix
}
last_counts[sn] = count
except Exception as e:
logger.warning(f"[shotmap_worker] error: {e}")
time.sleep(1)
@app.get("/image/{player_id_shots}") @app.get("/image/{player_id_shots}")
async def get_shotmap_image(player_id_shots: str): async def get_shotmap_image(player_id_shots: str):
""" """
@@ -3520,6 +3600,179 @@ async def get_shotmap_image(player_id_shots: str):
return Response(content=data, media_type="image/png") return Response(content=data, media_type="image/png")
@app.get("/last_5_games")
async def last_5_games():
# достаём актуальный game
game = get_latest_game_safe("game")
if not game:
raise HTTPException(status_code=503, detail="game data not ready")
game_data = game["data"] if "data" in game else game
result = game_data.get("result", {}) or {}
team1_info = result.get("team1") or {}
team2_info = result.get("team2") or {}
team1_id = team1_info.get("teamId")
team2_id = team2_info.get("teamId")
team1_name = team1_info.get("name", "")
team2_name = team2_info.get("name", "")
if not team1_id or not team2_id:
raise HTTPException(status_code=503, detail="team ids not ready")
if not CALENDAR or "items" not in CALENDAR:
raise HTTPException(status_code=503, detail="calendar data not ready")
final_states = {"result", "resultconfirmed", "finished"}
# последние N завершённых игр по команде
def collect_last_games_for_team(team_id: int, limit: int = 5):
matches = []
for item in CALENDAR["items"]:
game_info = item.get("game") or {}
if not game_info:
continue
status_raw = str(game_info.get("gameStatus", "") or "").lower()
if status_raw not in final_states:
# пропускаем незавершённые матчи
continue
t1 = item.get("team1") or {}
t2 = item.get("team2") or {}
if team_id not in (t1.get("teamId"), t2.get("teamId")):
continue
# пропускаем текущий матч
gid = game_info.get("id")
if gid is not None and GAME_ID is not None and str(gid) == str(GAME_ID):
continue
dt_str = game_info.get("defaultZoneDateTime") or ""
try:
dt = datetime.fromisoformat(dt_str)
except ValueError:
continue
matches.append({"dt": dt, "item": item})
matches.sort(key=lambda x: x["dt"], reverse=True)
return [m["item"] for m in matches[:limit]]
# считаем W/L для списка матчей команды
def calc_results_list(games: list[dict], team_id: int):
wl_list = []
for item in games:
game_info = item.get("game") or {}
t1 = item.get("team1") or {}
t2 = item.get("team2") or {}
score1 = game_info.get("score1")
score2 = game_info.get("score2")
id1 = t1.get("teamId")
id2 = t2.get("teamId")
is_win = None
if isinstance(score1, (int, float)) and isinstance(score2, (int, float)):
if team_id == id1:
is_win = score1 > score2
elif team_id == id2:
is_win = score2 > score1
wl_list.append("W" if is_win else "L" if is_win is not None else "")
return wl_list[::-1]
# ищем СЛЕДУЮЩИЙ матч (ближайший в будущем) для команды
def find_next_game(team_id: int):
if not CALENDAR or "items" not in CALENDAR:
return {"opponent": "", "date": "", "place": "", "place_ru": ""}
now = datetime.now() # наивное "сейчас"
best = None # {"dt": ..., "opp": ..., "place": "home"/"away"}
for item in CALENDAR["items"]:
game_info = item.get("game") or {}
t1 = item.get("team1") or {}
t2 = item.get("team2") or {}
if team_id not in (t1.get("teamId"), t2.get("teamId")):
continue
dt_str = game_info.get("defaultZoneDateTime") or ""
try:
dt = datetime.fromisoformat(dt_str)
except ValueError:
continue
# убираем tzinfo, чтобы можно было сравнивать с now
if dt.tzinfo is not None:
dt = dt.replace(tzinfo=None)
# только будущие матчи
if dt <= now:
continue
# определяем соперника и место
if team_id == t1.get("teamId"):
opp_name = t2.get("name", "")
place = "home" # команда дома
else:
opp_name = t1.get("name", "")
place = "away" # команда в гостях
if best is None or dt < best["dt"]:
best = {"dt": dt, "opp": opp_name, "place": place}
if not best:
return {"opponent": "", "date": "", "place": "", "place_ru": ""}
place_ru = "дома" if best["place"] == "home" else "в гостях"
return {
"opponent": best["opp"],
"date": best["dt"].strftime("%Y-%m-%d %H:%M"),
"place": best["place"], # "home" / "away"
"place_ru": place_ru, # "дома" / "в гостях"
}
# последние 5 игр и результаты
team1_games = collect_last_games_for_team(team1_id)
team2_games = collect_last_games_for_team(team2_id)
team1_wl = calc_results_list(team1_games, team1_id)
team2_wl = calc_results_list(team2_games, team2_id)
# следующий матч
next1 = find_next_game(team1_id)
next2 = find_next_game(team2_id)
data = [
{
"teamName": team1_name,
"teamId": team1_id,
"team_results": team1_wl,
"nextOpponent": next1["opponent"],
"nextGameDate": next1["date"],
"nextGamePlace": next1["place_ru"], # "дома" / "в гостях"
"nextGameHomeAway": next1["place"], # "home" / "away" (если нужно в логике)
},
{
"teamName": team2_name,
"teamId": team2_id,
"team_results": team2_wl,
"nextOpponent": next2["opponent"],
"nextGameDate": next2["date"],
"nextGamePlace": next2["place_ru"],
"nextGameHomeAway": next2["place"],
},
]
return data
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run( uvicorn.run(
"get_data:app", host="0.0.0.0", port=8000, reload=True, log_level="debug" "get_data:app", host="0.0.0.0", port=8000, reload=True, log_level="debug"