Files
hockey_new/hockey_data/config.py
2026-08-19 15:08:39 +03:00

444 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
import os
from pathlib import Path
from threading import RLock
from typing import Any
from urllib.parse import urlparse
DEFAULT_STRENGTH_STATE_LABELS: dict[str, dict[str, dict[str, str]]] = {
"regulation": {
"5x5": {"ru": "", "en": ""},
"5x4": {"ru": "PP", "en": "PP"},
"4x4": {"ru": "4 на 4", "en": "4 on 4"},
"5x3": {"ru": "5 на 3", "en": "5 on 3"},
"4x3": {"ru": "4 на 3", "en": "4 on 3"},
"3x3": {"ru": "3 на 3", "en": "3 on 3"},
},
"regular_overtime": {
"3x3": {"ru": "3 на 3", "en": "3 on 3"},
"4x3": {"ru": "4 на 3", "en": "4 on 3"},
"4x4": {"ru": "4 на 4", "en": "4 on 4"},
"5x3": {"ru": "5 на 3", "en": "5 on 3"},
"5x4": {"ru": "5 на 4", "en": "5 on 4"},
"5x5": {"ru": "5 на 5", "en": "5 on 5"},
},
"playoff_overtime": {
"5x5": {"ru": "", "en": ""},
"5x4": {"ru": "PP", "en": "PP"},
"4x4": {"ru": "4 на 4", "en": "4 on 4"},
"5x3": {"ru": "5 на 3", "en": "5 on 3"},
"4x3": {"ru": "4 на 3", "en": "4 on 3"},
"3x3": {"ru": "3 на 3", "en": "3 on 3"},
},
}
DEFAULT_PERIOD_STATUS_LABELS: dict[str, dict[str, dict[str, str]]] = {
"1": {
"compact": {"ru": "1", "en": "1"},
"short": {"ru": "1 ПЕР", "en": "P1"},
"long": {"ru": "1 период", "en": "1st period"},
},
"2": {
"compact": {"ru": "2", "en": "2"},
"short": {"ru": "2 ПЕР", "en": "P2"},
"long": {"ru": "2 период", "en": "2nd period"},
},
"3": {
"compact": {"ru": "3", "en": "3"},
"short": {"ru": "3 ПЕР", "en": "P3"},
"long": {"ru": "3 период", "en": "3rd period"},
},
"ot": {
"compact": {"ru": "ОТ", "en": "OT"},
"short": {"ru": "ОТ", "en": "OT"},
"long": {"ru": "Овертайм", "en": "Overtime"},
},
"ot_numbered": {
"compact": {"ru": "ОТ{n}", "en": "OT{n}"},
"short": {"ru": "ОТ {n}", "en": "OT {n}"},
"long": {"ru": "{n} овертайм", "en": "Overtime {n}"},
},
"so": {
"compact": {"ru": "Б", "en": "SO"},
"short": {"ru": "БУЛ", "en": "SO"},
"long": {"ru": "Буллиты", "en": "Shootout"},
},
"finished": {
"compact": {"ru": "КОН", "en": "FIN"},
"short": {"ru": "ЗАВ", "en": "FINAL"},
"long": {"ru": "Матч завершён", "en": "Final"},
},
}
DEFAULT_SCOREBOARD_TEAM_STATES: dict[str, dict[str, Any]] = {
"home_delayed_penalty": {
"ru": "Отложенный штраф",
"en": "Delayed penalty",
"input": "",
"input_title": "",
"overlay": "2",
"enabled": True,
},
"away_delayed_penalty": {
"ru": "Отложенный штраф",
"en": "Delayed penalty",
"input": "",
"input_title": "",
"overlay": "2",
"enabled": True,
},
"home_empty_net": {
"ru": "Пустые ворота",
"en": "Empty net",
"input": "",
"input_title": "",
"overlay": "3",
"enabled": True,
},
"away_empty_net": {
"ru": "Пустые ворота",
"en": "Empty net",
"input": "",
"input_title": "",
"overlay": "3",
"enabled": True,
},
}
DEFAULT_PUBLIC_SETTINGS: dict[str, Any] = {
"base_url": "https://stat2tv.khl.ru/",
"auth_mode": "auto",
"ui_language": "ru",
"fallback_language": "ru",
"store_ru": True,
"store_en": True,
"sync_interval_seconds": 300,
"request_timeout_seconds": 20,
"verify_ssl": True,
"timezone": "Europe/Moscow",
"auto_sync_on_startup": False,
"games_endpoint_template": "{tournament_id}/schedule-{tournament_id}-live.xml",
"games_auto_discovery": True,
"games_cache_seconds": 300,
"players_endpoint_template": "{tournament_id}/players-{tournament_id}.xml",
"player_countries_endpoint_template": "players-countries.xml",
"referees_endpoint_template": "{tournament_id}/referees-{tournament_id}.xml",
"match_json_endpoint_template": "{tournament_id}/json/{game_id}.json",
"match_json_en_endpoint_template": "{tournament_id}/json_en/{game_id}.json",
"shots_endpoint_template": "shots/shots-{game_id}.json",
"match_directories_cache_seconds": 300,
"standings_endpoint_template": "{tournament_id}/standings-{tournament_id}.xml",
"standings_cache_seconds": 300,
"powerplay_endpoint_template": "{tournament_id}/powerplay-{tournament_id}.xml",
"rank_endpoint_template": "{tournament_id}/rank-{tournament_id}.xml",
"navigation_endpoint_template": "{tournament_id}/navigation-{tournament_id}.xml",
"tournament_statistics_cache_seconds": 300,
"optional_resource_negative_cache_seconds": 86400,
# Operator timer defaults. Values are global, but regular-season and
# playoff overtime are configured separately.
"timer_period_minutes": 20,
"timer_regular_overtime_minutes": 5,
"timer_playoff_overtime_minutes": 20,
"timer_reset_on_period_change": True,
# Strength / power-play graphic rules.
"strength_regulation_skaters": 5,
"strength_regular_overtime_skaters": 3,
"strength_playoff_overtime_skaters": 5,
"strength_min_skaters": 3,
"strength_regulation_display": "pp",
"strength_regular_overtime_display": "numbers",
"strength_playoff_overtime_display": "pp",
"strength_powerplay_label": "PP",
"strength_shorthanded_label": "PK",
"strength_show_shorthanded": False,
"strength_state_labels": DEFAULT_STRENGTH_STATE_LABELS,
"period_status_labels": DEFAULT_PERIOD_STATUS_LABELS,
"scoreboard_team_states": DEFAULT_SCOREBOARD_TEAM_STATES,
}
DEFAULT_SECRET_SETTINGS: dict[str, str] = {
"username": "",
"password": "",
}
class HockeySettingsStore:
"""Public settings and local Stat2TV credentials.
Credentials are kept in a separate local file and are never returned by API.
Environment variables override local values:
STAT2TV_BASE_URL
STAT2TV_LOGIN
STAT2TV_PASSWORD
STAT2TV_AUTH_MODE
STAT2TV_VERIFY_SSL
"""
def __init__(self, settings_dir: Path) -> None:
self.settings_dir = Path(settings_dir)
self.settings_dir.mkdir(parents=True, exist_ok=True)
self.public_file = self.settings_dir / "hockey_api.json"
self.secret_file = self.settings_dir / "stat2tv_credentials.local.json"
self._lock = RLock()
self._ensure_files()
def _ensure_files(self) -> None:
if not self.public_file.exists():
self.public_file.write_text(
json.dumps(DEFAULT_PUBLIC_SETTINGS, ensure_ascii=False, indent=2),
encoding="utf-8",
)
if not self.secret_file.exists():
self.secret_file.write_text(
json.dumps(DEFAULT_SECRET_SETTINGS, ensure_ascii=False, indent=2),
encoding="utf-8",
)
try:
os.chmod(self.secret_file, 0o600)
except OSError:
pass
@staticmethod
def _read_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, TypeError):
return {}
return value if isinstance(value, dict) else {}
@staticmethod
def _normalise_url(value: str) -> str:
value = (value or "").strip()
if not value:
value = DEFAULT_PUBLIC_SETTINGS["base_url"]
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("Некорректный адрес Stat2TV")
return value.rstrip("/") + "/"
@staticmethod
def _language(value: Any, fallback: str) -> str:
value = str(value or "").lower().strip()
return value if value in {"ru", "en"} else fallback
def public(self) -> dict[str, Any]:
with self._lock:
raw = {**DEFAULT_PUBLIC_SETTINGS, **self._read_json(self.public_file)}
raw["base_url"] = self._normalise_url(
os.getenv("STAT2TV_BASE_URL", str(raw["base_url"]))
)
raw["auth_mode"] = str(
os.getenv("STAT2TV_AUTH_MODE", raw["auth_mode"])
).lower()
if raw["auth_mode"] not in {"auto", "basic", "digest"}:
raw["auth_mode"] = "auto"
raw["ui_language"] = self._language(raw.get("ui_language"), "ru")
raw["fallback_language"] = self._language(
raw.get("fallback_language"), "ru"
)
raw["store_ru"] = bool(raw.get("store_ru", True))
raw["store_en"] = bool(raw.get("store_en", True))
if not raw["store_ru"] and not raw["store_en"]:
raw["store_ru"] = True
raw["store_en"] = True
for key, low, high, fallback in (
("sync_interval_seconds", 30, 86400, 300),
("request_timeout_seconds", 3, 120, 20),
("games_cache_seconds", 0, 86400, 300),
("match_directories_cache_seconds", 0, 86400, 300),
("standings_cache_seconds", 0, 86400, 300),
("tournament_statistics_cache_seconds", 0, 86400, 300),
("optional_resource_negative_cache_seconds", 300, 604800, 86400),
("timer_period_minutes", 1, 60, 20),
("timer_regular_overtime_minutes", 1, 60, 5),
("timer_playoff_overtime_minutes", 1, 60, 20),
("strength_regulation_skaters", 3, 6, 5),
("strength_regular_overtime_skaters", 3, 6, 3),
("strength_playoff_overtime_skaters", 3, 6, 5),
("strength_min_skaters", 2, 5, 3),
):
try:
value = int(raw.get(key, fallback))
except (TypeError, ValueError):
value = fallback
raw[key] = max(low, min(high, value))
env_verify = os.getenv("STAT2TV_VERIFY_SSL")
if env_verify is not None:
raw["verify_ssl"] = env_verify.strip().lower() in {
"1", "true", "yes", "on"
}
else:
raw["verify_ssl"] = bool(raw.get("verify_ssl", True))
raw["timezone"] = str(raw.get("timezone") or "Europe/Moscow")
raw["auto_sync_on_startup"] = bool(raw.get("auto_sync_on_startup", False))
raw["timer_reset_on_period_change"] = bool(raw.get("timer_reset_on_period_change", True))
raw["strength_show_shorthanded"] = bool(raw.get("strength_show_shorthanded", False))
display_modes = {"pp", "numbers", "pp_numbers"}
for key, fallback in (
("strength_regulation_display", "pp"),
("strength_regular_overtime_display", "numbers"),
("strength_playoff_overtime_display", "pp"),
):
value = str(raw.get(key) or fallback).strip().lower()
raw[key] = value if value in display_modes else fallback
raw["strength_powerplay_label"] = str(raw.get("strength_powerplay_label") or "PP").strip()[:12] or "PP"
raw["strength_shorthanded_label"] = str(raw.get("strength_shorthanded_label") or "PK").strip()[:12] or "PK"
source_labels = raw.get("strength_state_labels")
source_labels = source_labels if isinstance(source_labels, dict) else {}
normalised_labels: dict[str, dict[str, dict[str, str]]] = {}
for phase, defaults in DEFAULT_STRENGTH_STATE_LABELS.items():
phase_source = source_labels.get(phase)
phase_source = phase_source if isinstance(phase_source, dict) else {}
phase_result: dict[str, dict[str, str]] = {}
for state_key, default_labels in defaults.items():
state_source = phase_source.get(state_key)
state_source = state_source if isinstance(state_source, dict) else {}
phase_result[state_key] = {
"ru": str(state_source.get("ru", default_labels["ru"]) or "").strip()[:40],
"en": str(state_source.get("en", default_labels["en"]) or "").strip()[:40],
}
normalised_labels[phase] = phase_result
raw["strength_state_labels"] = normalised_labels
source_period_labels = raw.get("period_status_labels")
source_period_labels = source_period_labels if isinstance(source_period_labels, dict) else {}
normalised_period_labels: dict[str, dict[str, dict[str, str]]] = {}
for period_key, defaults in DEFAULT_PERIOD_STATUS_LABELS.items():
source_period = source_period_labels.get(period_key)
source_period = source_period if isinstance(source_period, dict) else {}
period_result: dict[str, dict[str, str]] = {}
for format_key in ("compact", "short", "long"):
default_format = defaults[format_key]
source_format = source_period.get(format_key)
source_format = source_format if isinstance(source_format, dict) else {}
period_result[format_key] = {
"ru": str(source_format.get("ru", default_format["ru"]) or "").strip()[:64],
"en": str(source_format.get("en", default_format["en"]) or "").strip()[:64],
}
normalised_period_labels[period_key] = period_result
raw["period_status_labels"] = normalised_period_labels
source_team_states = raw.get("scoreboard_team_states")
source_team_states = source_team_states if isinstance(source_team_states, dict) else {}
normalised_team_states: dict[str, dict[str, Any]] = {}
for state_key, defaults in DEFAULT_SCOREBOARD_TEAM_STATES.items():
source = source_team_states.get(state_key)
source = source if isinstance(source, dict) else {}
overlay = str(source.get("overlay") or defaults["overlay"]).strip()
if overlay not in {"1", "2", "3", "4"}:
overlay = str(defaults["overlay"])
normalised_team_states[state_key] = {
"ru": str(source.get("ru", defaults["ru"]) or "").strip()[:48],
"en": str(source.get("en", defaults["en"]) or "").strip()[:48],
"input": str(source.get("input") or "").strip()[:300],
"input_title": str(source.get("input_title") or "").strip()[:300],
"overlay": overlay,
"enabled": bool(source.get("enabled", defaults["enabled"])),
}
raw["scoreboard_team_states"] = normalised_team_states
raw["games_endpoint_template"] = str(
raw.get("games_endpoint_template") or ""
).strip()
raw["games_auto_discovery"] = bool(
raw.get("games_auto_discovery", True)
)
for key in (
"players_endpoint_template",
"player_countries_endpoint_template",
"referees_endpoint_template",
"match_json_endpoint_template",
"match_json_en_endpoint_template",
"shots_endpoint_template",
"standings_endpoint_template",
"powerplay_endpoint_template",
"rank_endpoint_template",
"navigation_endpoint_template",
):
raw[key] = str(
raw.get(key) or DEFAULT_PUBLIC_SETTINGS[key]
).strip()
return raw
def credentials(self) -> tuple[str, str]:
with self._lock:
raw = {**DEFAULT_SECRET_SETTINGS, **self._read_json(self.secret_file)}
username = os.getenv("STAT2TV_LOGIN", str(raw.get("username") or ""))
password = os.getenv("STAT2TV_PASSWORD", str(raw.get("password") or ""))
return username.strip(), password
def status(self) -> dict[str, Any]:
settings = self.public()
username, password = self.credentials()
return {
**settings,
"credentials_configured": bool(username and password),
"username_masked": self.mask_username(username),
}
@staticmethod
def mask_username(username: str) -> str:
if not username:
return ""
if len(username) <= 2:
return "" * len(username)
return f"{username[0]}{'' * max(2, len(username)-2)}{username[-1]}"
def update_public(self, payload: dict[str, Any]) -> dict[str, Any]:
allowed = set(DEFAULT_PUBLIC_SETTINGS)
with self._lock:
current = {**DEFAULT_PUBLIC_SETTINGS, **self._read_json(self.public_file)}
for key in allowed:
if key in payload:
current[key] = payload[key]
current["base_url"] = self._normalise_url(str(current["base_url"]))
current["ui_language"] = self._language(current["ui_language"], "ru")
current["fallback_language"] = self._language(
current["fallback_language"], "ru"
)
self.public_file.write_text(
json.dumps(current, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return self.status()
def update_credentials(
self,
*,
username: str | None,
password: str | None,
clear_password: bool = False,
) -> dict[str, Any]:
with self._lock:
current = {**DEFAULT_SECRET_SETTINGS, **self._read_json(self.secret_file)}
if username is not None:
current["username"] = username.strip()
if clear_password:
current["password"] = ""
elif password not in (None, ""):
current["password"] = str(password)
self.secret_file.write_text(
json.dumps(current, ensure_ascii=False, indent=2),
encoding="utf-8",
)
try:
os.chmod(self.secret_file, 0o600)
except OSError:
pass
return self.status()