# services/vmix_json_service.py from db import get_connection from repositories.match_lineup_repository import get_match_lineup_for_vmix from parsers.parser_sources import build_empty_photo_path, build_logo_path, build_logo_variant_path, build_photo_path DEFAULT_PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" EMPTY_PHOTO_PATH = DEFAULT_PHOTO_BASE_PATH + r"\EMPTY.png" def _session_source_key(session_row) -> str | None: try: value = session_row[21] except Exception: value = None return str(value).strip() if value else None def build_generated_player_photo_path( team_name: str, last_name: str, first_name: str, source_key: str | None = None, ) -> str: relative_file = ( str(team_name or "").strip() + "\\" + (str(last_name or "") + " " + str(first_name or "")).strip() + ".png" ) return build_photo_path(source_key, relative_file) def resolve_player_photo( photo_enabled: bool, generated_photo: str, source_key: str | None = None, ) -> str: if photo_enabled: return generated_photo return build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH def _normalize_text(value) -> str: return " ".join(str(value or "").strip().lower().split()) def _player_name_variants(player: dict) -> set[str]: first_name = _normalize_text(player.get("first_name")) last_name = _normalize_text(player.get("last_name")) player_name = _normalize_text(player.get("player_name")) full_name = _normalize_text(player.get("full_name")) variants = {player_name, full_name} if first_name or last_name: variants.add(f"{last_name} {first_name}".strip()) variants.add(f"{first_name} {last_name}".strip()) return {v for v in variants if v} def _candidate_name_variants(row: tuple) -> set[str]: _, full_name, last_name, first_name, *_ = row full_name = _normalize_text(full_name) last_name = _normalize_text(last_name) first_name = _normalize_text(first_name) variants = {full_name} if first_name or last_name: variants.add(f"{last_name} {first_name}".strip()) variants.add(f"{first_name} {last_name}".strip()) return {v for v in variants if v} def _find_player_photo_state( *, player_id=None, team_id=None, player_name: str = "", first_name: str = "", last_name: str = "", full_name: str = "", number: str = "", ) -> tuple[str | None, bool] | None: """ Берёт актуальное состояние галочки photo_enabled прямо из players. Если в составе нет корректного player_id, пробует найти игрока по команде, номеру и имени. Это важно для старых/загруженных с сайта составов, где player_id мог быть пустым. Значение p.photo здесь не используется для JSON-пути: путь генерируется по старой схеме. """ try: player_id_int = int(player_id) if str(player_id or "").strip().isdigit() else None except Exception: player_id_int = None try: team_id_int = int(team_id) if str(team_id or "").strip().isdigit() else None except Exception: team_id_int = None target_number = str(number or "").strip() target_player = { "player_name": player_name, "first_name": first_name, "last_name": last_name, "full_name": full_name, } target_names = _player_name_variants(target_player) where_parts = [] params = [] if player_id_int is not None: where_parts.append("p.id = %s") params.append(player_id_int) if team_id_int is not None: team_conditions = [] team_params = [] if target_number: team_conditions.append("COALESCE(p.number::text, '') = %s") team_params.append(target_number) for name in sorted(target_names): like = f"%{name}%" team_conditions.append( """ ( LOWER(COALESCE(p.full_name, '')) = %s OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = %s OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = %s OR LOWER(COALESCE(p.full_name, '')) LIKE %s ) """ ) team_params.extend([name, name, name, like]) if team_conditions: where_parts.append(f"(p.team_id = %s AND ({' OR '.join(team_conditions)}))") params.append(team_id_int) params.extend(team_params) if not where_parts: return None query = f""" SELECT p.id, COALESCE(p.full_name, ''), COALESCE(p.last_name, ''), COALESCE(p.first_name, ''), COALESCE(p.number::text, ''), p.team_id, p.photo, COALESCE(p.photo_enabled, FALSE) AS photo_enabled FROM players p WHERE {' OR '.join(where_parts)} LIMIT 50 """ conn = get_connection() try: with conn.cursor() as cur: cur.execute(query, tuple(params)) rows = cur.fetchall() finally: conn.close() best_row = None best_score = -1 for row in rows: row_id, _, _, _, row_number, row_team_id, *_ = row row_names = _candidate_name_variants(row) id_match = player_id_int is not None and int(row_id) == player_id_int team_match = team_id_int is not None and int(row_team_id) == team_id_int number_match = bool(target_number) and str(row_number or "").strip() == target_number name_match = bool(target_names.intersection(row_names)) partial_name_match = False if target_names and row_names: partial_name_match = any( target in candidate or candidate in target for target in target_names for candidate in row_names if len(target) >= 4 and len(candidate) >= 4 ) # Если ID выглядит как номер игрока и случайно совпал с чужим players.id, # не считаем его надёжным без совпадения команды/имени/номера. trusted_id_match = id_match and ( (team_id_int is None or team_match) and (not target_number and not target_names or number_match or name_match or partial_name_match) ) score = 0 if trusted_id_match: score += 100 if team_match: score += 40 if number_match: score += 30 if name_match: score += 35 elif partial_name_match: score += 15 # Для поиска без надёжного ID нужно хотя бы совпадение команды и номера/имени. if not trusted_id_match and not (team_match and (number_match or name_match or partial_name_match)): continue if score > best_score: best_score = score best_row = row if not best_row: return None return best_row[6], bool(best_row[7]) def resolve_player_photo_for_json( player: dict, generated_photo: str, fallback_team_id=None, source_key: str | None = None, ) -> str: state = _find_player_photo_state( player_id=player.get("player_id") or player.get("id"), team_id=player.get("team_id") or fallback_team_id, player_name=player.get("player_name") or "", first_name=player.get("first_name") or "", last_name=player.get("last_name") or "", full_name=player.get("full_name") or "", number=player.get("number") or player.get("player_number") or "", ) if state is not None: photo_value, photo_enabled = state resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo return resolve_player_photo( photo_enabled=photo_enabled, generated_photo=resolved_photo, source_key=source_key, ) photo_value = player.get("photo") or "" resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo return resolve_player_photo( photo_enabled=player.get("photo_enabled"), generated_photo=resolved_photo, source_key=source_key, ) def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name, source_key=None): lineups = get_match_lineup_for_vmix( match_id=match_id, home_team_id=home_team_id, away_team_id=away_team_id, ) players = lineups.get(name, []) result = [] fallback_team_id = home_team_id if str(name).startswith("home") else away_team_id for p in players: suffix = [] if "вратарь" in (p.get("pos") or "").lower(): suffix.append("ВР") if p.get("is_captain"): suffix.append("К") number = p.get("number", "") lastname = p.get("last_name", "") suffix_str = f'({", ".join(suffix)})' if suffix else "" result.append( { "number": p.get("number", ""), "number_lastname_amp_K": f"{number} {lastname} {suffix_str}".strip(), "first_name": p.get("first_name", ""), "last_name": p.get("last_name", ""), "number_fullname": f"{number} {p.get('first_name', '')} {p.get('last_name', '')}".strip(), "full_name": ( p.get("first_name", "") + " " + p.get("last_name", "") ).strip(), "full_name_K": ( p.get("first_name", "") + " " + p.get("last_name", "") ).strip() + (f" {', '.join(suffix)}" if suffix else ""), "pos": p.get("pos", ""), "position": p.get("position", ""), "photo": resolve_player_photo_for_json( p, generated_photo=build_generated_player_photo_path( team_name=team_a_name if "home" in name else team_b_name, last_name=p.get("last_name", ""), first_name=p.get("first_name", ""), source_key=source_key, ), fallback_team_id=fallback_team_id, source_key=source_key, ), } ) return {"players": result} def get_vmix_match_info_by_token(session_token: str): query = """ SELECT ms.match_id, m.match_date, m.place, m.stadium_id, ht.id AS home_team_id, ht.name AS home_team_name, COALESCE(ht.full_name, ht.name) AS home_team_full_name, COALESCE(ht.short_name_3, '') AS home_team_short_name, at.id AS away_team_id, at.name AS away_team_name, COALESCE(at.full_name, at.name) AS away_team_full_name, COALESCE(at.short_name_3, '') AS away_team_short_name, COALESCE(s.stadium_gfx, s.name, m.place, '') AS stadium_name, m.tour, ht.logo_path AS home_logo, at.logo_path AS away_logo, ref1.referee_name AS referee1, ref2.referee_name AS referee2, ref3.referee_name AS referee3, ref4.referee_name AS referee4, ht.logo_path AS home_logo1, at.logo_path AS away_logo1, ht.logo_path AS home_logo2, at.logo_path AS away_logo2, ht.city AS home_city, at.city AS away_city, TRIM(COALESCE(c1.name, '') || ' ' || COALESCE(c1.lastname, '')) AS coach_name1, c1.amplua AS coach_amplua1, TRIM(COALESCE(c2.name, '') || ' ' || COALESCE(c2.lastname, '')) AS coach_name2, c2.amplua AS coach_amplua2, m.source_key AS source_key FROM match_sessions ms JOIN matches m ON m.id = ms.match_id JOIN teams ht ON ht.id = m.home_team_id JOIN teams at ON at.id = m.away_team_id LEFT JOIN match_referees ref1 ON ref1.match_id = m.id AND ref1.role = 'Главный судья' LEFT JOIN match_referees ref2 ON ref2.match_id = m.id AND ref2.role = 'Ассистент судьи №1' LEFT JOIN match_referees ref3 ON ref3.match_id = m.id AND ref3.role = 'Ассистент судьи №2' LEFT JOIN match_referees ref4 ON ref4.match_id = m.id AND ref4.role = 'Резервный судья' LEFT JOIN match_coaches mc1 ON mc1.match_id = m.id AND mc1.side = 'home' LEFT JOIN coaches c1 ON c1.id = mc1.coach_id LEFT JOIN match_coaches mc2 ON mc2.match_id = m.id AND mc2.side = 'away' LEFT JOIN coaches c2 ON c2.id = mc2.coach_id LEFT JOIN stadiums s ON s.id = m.stadium_id WHERE ms.session_token = %s LIMIT 1; """ conn = get_connection() try: with conn.cursor() as cur: cur.execute(query, (session_token,)) row = cur.fetchone() if not row: return None row = list(row) source_key = row[30] if len(row) > 30 else None row[14] = build_logo_path(source_key, row[14]) row[15] = build_logo_path(source_key, row[15]) row[20] = build_logo_variant_path(source_key, row[20], "white") row[21] = build_logo_variant_path(source_key, row[21], "white") row[22] = build_logo_variant_path(source_key, row[22], "blue") row[23] = build_logo_variant_path(source_key, row[23], "blue") return tuple(row) finally: conn.close() def get_vmix_standings(session_row): source_key = _session_source_key(session_row) season = session_row[11] if len(session_row) > 11 else None query = """ SELECT s.position, COALESCE(t.full_name, t.name) AS team_name, t.logo_path AS logo, s.played, s.wins, s.losses, s.draws, s.points_for || ' - ' || s.points_against AS score, s.points, s.team_id FROM standings s LEFT JOIN teams t ON s.team_id = t.id WHERE (%s IS NULL OR s.season = %s) ORDER BY s.position """ conn = get_connection() try: with conn.cursor() as cur: cur.execute(query, (season, season)) rows = cur.fetchall() result = [] for row in rows: row = list(row) row[2] = build_logo_variant_path(source_key, row[2], "white") result.append(tuple(row)) return result finally: conn.close() def get_vmix_schedule(session_row): source_key = _session_source_key(session_row) tour = session_row[10] if len(session_row) > 10 else None season = session_row[11] if len(session_row) > 11 else None source_filter = source_key or "SUPERLEAGUE" query = """ SELECT t1.logo_path AS logo1, t2.logo_path AS logo2, m.home_score, m.away_score, m.match_date, m.status, m.id, m.channel FROM matches m LEFT JOIN teams t1 ON m.home_team_id = t1.id LEFT JOIN teams t2 ON m.away_team_id = t2.id WHERE m.tour = %s AND (%s IS NULL OR m.season = %s) AND COALESCE(m.source_key, %s) = %s ORDER BY m.match_date, m.id """ conn = get_connection() try: with conn.cursor() as cur: cur.execute(query, (tour, season, season, source_filter, source_filter)) rows = cur.fetchall() result = [] for row in rows: row = list(row) row[0] = build_logo_variant_path(source_key, row[0], "white") row[1] = build_logo_variant_path(source_key, row[1], "white") result.append(tuple(row)) return result finally: conn.close() def get_vmix_team_formations(session_token: str, team_id: int): query = """ SELECT COALESCE(NULLIF(p.last_name, ''), NULLIF(mf.player_name, ''), '') AS last_name, mf.is_captain, COALESCE(NULLIF(mf.number, ''), p.number::text, '') AS number, COALESCE(NULLIF(mf.position, ''), p.position, '') AS position, COALESCE(p.first_name, '') AS first_name, p.photo, COALESCE(p.photo_enabled, FALSE) AS photo_enabled, mf.player_id, mf.team_id, COALESCE(mf.player_name, '') AS player_name FROM match_formations mf LEFT JOIN LATERAL ( SELECT p.* FROM players p WHERE p.team_id = mf.team_id AND ( p.id = mf.player_id OR ( COALESCE(p.number::text, '') <> '' AND COALESCE(p.number::text, '') = COALESCE(mf.number, '') ) OR ( COALESCE(mf.player_name, '') <> '' AND ( LOWER(COALESCE(p.full_name, '')) = LOWER(TRIM(mf.player_name)) OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = LOWER(TRIM(mf.player_name)) OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = LOWER(TRIM(mf.player_name)) ) ) ) ORDER BY CASE WHEN p.id = mf.player_id AND ( COALESCE(mf.number, '') = '' OR COALESCE(p.number::text, '') = COALESCE(mf.number, '') OR LOWER(COALESCE(p.full_name, '')) = LOWER(TRIM(COALESCE(mf.player_name, ''))) OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = LOWER(TRIM(COALESCE(mf.player_name, ''))) OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = LOWER(TRIM(COALESCE(mf.player_name, ''))) ) THEN 0 WHEN COALESCE(p.number::text, '') = COALESCE(mf.number, '') AND COALESCE(mf.player_name, '') <> '' THEN 1 WHEN COALESCE(mf.player_name, '') <> '' THEN 2 WHEN COALESCE(p.number::text, '') = COALESCE(mf.number, '') THEN 3 ELSE 4 END, p.id LIMIT 1 ) p ON TRUE WHERE mf.match_id = %s and mf.team_id = %s ORDER BY mf.id """ conn = get_connection() try: with conn.cursor() as cur: cur.execute(query, (session_token[1], team_id)) return cur.fetchall() finally: conn.close() def get_vmix_scoreboard_info(session_token: str): query = """ WITH yellow_reds AS ( SELECT side, player_id FROM match_events_ui WHERE match_id = %s AND type = 'yellow' AND player_id IS NOT NULL GROUP BY side, player_id HAVING COUNT(*) >= 2 ), direct_reds AS ( SELECT side, COUNT(*) AS cnt FROM match_events_ui WHERE match_id = %s AND type = 'red' GROUP BY side ), two_yellow_reds AS ( SELECT side, COUNT(*) AS cnt FROM yellow_reds GROUP BY side ), red_totals AS ( SELECT s.side, COALESCE(dr.cnt, 0) + COALESCE(tyr.cnt, 0) AS red_count FROM ( SELECT 'home' AS side UNION ALL SELECT 'away' AS side ) s LEFT JOIN direct_reds dr ON dr.side = s.side LEFT JOIN two_yellow_reds tyr ON tyr.side = s.side ) SELECT -- голы COALESCE(SUM( CASE WHEN m.side = 'home' AND m.type IN ('goal', 'penalty', 'own_goal') THEN 1 ELSE 0 END ), 0) AS home_goals, COALESCE(SUM( CASE WHEN m.side = 'away' AND m.type IN ('goal', 'penalty', 'own_goal') THEN 1 ELSE 0 END ), 0) AS away_goals, -- домашние карточки CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'home') >= 1 THEN '#FF0000' ELSE '#FF000000' END AS home_red_1, CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'home') >= 2 THEN '#FF0000' ELSE '#FF000000' END AS home_red_2, CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'home') >= 3 THEN '#FF0000' ELSE '#FF000000' END AS home_red_3, CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'home') >= 4 THEN '#FF0000' ELSE '#FF000000' END AS home_red_4, -- гостевые карточки CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'away') >= 1 THEN '#FF0000' ELSE '#FF000000' END AS away_red_1, CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'away') >= 2 THEN '#FF0000' ELSE '#FF000000' END AS away_red_2, CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'away') >= 3 THEN '#FF0000' ELSE '#FF000000' END AS away_red_3, CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'away') >= 4 THEN '#FF0000' ELSE '#FF000000' END AS away_red_4 FROM match_events_ui m WHERE m.match_id = %s; """ conn = get_connection() try: with conn.cursor() as cur: cur.execute(query, (session_token[1],session_token[1],session_token[1])) return cur.fetchone() finally: conn.close() def get_vmix_players_goal2(match_id: int): """запрос для получения игроков, забивших голы, с указанием минут и типа гола (основной, пенальти, автогол) Иванов 23', 25' (дубли пишуться вместе) """ query = """ WITH goal_events AS ( SELECT meu.side, COALESCE(p.last_name, meu.player_name) AS last_name, meu.seconds, meu.id, meu.type, CASE WHEN EXISTS ( SELECT 1 FROM match_events_ui m2 WHERE m2.match_id = meu.match_id AND m2.type = 'period_start_2h' AND m2.seconds <= meu.seconds ) THEN 2 WHEN EXISTS ( SELECT 1 FROM match_events_ui m2 WHERE m2.match_id = meu.match_id AND m2.type = 'period_start_1h' AND m2.seconds <= meu.seconds ) THEN 1 ELSE NULL END AS period_no FROM match_events_ui meu LEFT JOIN players p ON p.id = meu.player_id WHERE meu.match_id = %s AND meu.type IN ('goal', 'penalty', 'own_goal') ), grouped_players AS ( SELECT side, last_name, MIN(seconds) AS first_goal_seconds, STRING_AGG( ( CASE WHEN period_no = 1 AND seconds > 2700 THEN '45''' || '+' || ((seconds - 2700) / 60)::int::text WHEN period_no = 2 AND seconds > 5400 THEN '90''' || '+' || ((seconds - 5400) / 60)::int::text WHEN period_no = 2 THEN (46 + ((seconds - 2700) / 60)::int)::text || '''' ELSE (1 + (seconds / 60)::int)::text || '''' END ) || CASE WHEN type = 'own_goal' THEN ' (АГ)' WHEN type = 'penalty' THEN ' (П)' ELSE '' END, ', ' ORDER BY seconds, id ) AS goal_minutes FROM goal_events GROUP BY side, last_name ), home_rows AS ( SELECT ROW_NUMBER() OVER (ORDER BY first_goal_seconds, last_name) AS rn, last_name || ' ' || goal_minutes AS player_name1 FROM grouped_players WHERE side = 'home' ), away_rows AS ( SELECT ROW_NUMBER() OVER (ORDER BY first_goal_seconds, last_name) AS rn, last_name || ' ' || goal_minutes AS player_name2 FROM grouped_players WHERE side = 'away' ) SELECT COALESCE(h.player_name1, '') AS player_name1, COALESCE(a.player_name2, '') AS player_name2 FROM home_rows h FULL OUTER JOIN away_rows a ON a.rn = h.rn ORDER BY COALESCE(h.rn, a.rn); """ conn = get_connection() try: with conn.cursor() as cur: cur.execute(query, (match_id,)) return cur.fetchall() finally: conn.close() def get_vmix_players_goal(match_id: int): """запрос для получения игроков, забивших голы, с указанием минут и типа гола (основной, пенальти, автогол) Каждый гол в отдельной строке, дубли по игроку не объединяются """ query = """ WITH goal_events AS ( SELECT meu.side, COALESCE(p.last_name, meu.player_name) AS last_name, meu.seconds, meu.id, meu.type, CASE WHEN EXISTS ( SELECT 1 FROM match_events_ui m2 WHERE m2.match_id = meu.match_id AND m2.type = 'period_start_2h' AND m2.seconds <= meu.seconds ) THEN 2 WHEN EXISTS ( SELECT 1 FROM match_events_ui m2 WHERE m2.match_id = meu.match_id AND m2.type = 'period_start_1h' AND m2.seconds <= meu.seconds ) THEN 1 ELSE NULL END AS period_no FROM match_events_ui meu LEFT JOIN players p ON p.id = meu.player_id WHERE meu.match_id = %s AND meu.type IN ('goal', 'penalty', 'own_goal') ), goal_rows AS ( SELECT side, last_name, seconds, id, last_name || ' ' || ( CASE WHEN period_no = 1 AND seconds > 2700 THEN '45''' || '+' || ((seconds - 2700) / 60)::int::text WHEN period_no = 2 AND seconds > 5400 THEN '90''' || '+' || ((seconds - 5400) / 60)::int::text WHEN period_no = 2 THEN (46 + ((seconds - 2700) / 60)::int)::text || '''' ELSE (1 + (seconds / 60)::int)::text || '''' END ) || CASE WHEN type = 'own_goal' THEN ' (АГ)' WHEN type = 'penalty' THEN ' (П)' ELSE '' END AS player_goal FROM goal_events ), home_rows AS ( SELECT ROW_NUMBER() OVER (ORDER BY seconds, id, last_name) AS rn, player_goal AS player_name1 FROM goal_rows WHERE side = 'home' ), away_rows AS ( SELECT ROW_NUMBER() OVER (ORDER BY seconds, id, last_name) AS rn, player_goal AS player_name2 FROM goal_rows WHERE side = 'away' ) SELECT COALESCE(h.player_name1, '') AS player_name1, COALESCE(a.player_name2, '') AS player_name2 FROM home_rows h FULL OUTER JOIN away_rows a ON a.rn = h.rn ORDER BY COALESCE(h.rn, a.rn); """ conn = get_connection() try: with conn.cursor() as cur: cur.execute(query, (match_id,)) return cur.fetchall() finally: conn.close()