This commit is contained in:
2026-08-19 15:08:39 +03:00
parent 478557f3ad
commit a03c54dbe8
5148 changed files with 31553313 additions and 0 deletions

443
hockey_data/config.py Normal file
View File

@@ -0,0 +1,443 @@
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()