1. поправлено работа с таймерами в vMix
2. поправлено немного Заготовки
This commit is contained in:
@@ -1,22 +1,24 @@
|
|||||||
# Защита конструктора
|
# Защита конструктора
|
||||||
|
|
||||||
## Формула ежедневного PIN
|
## PIN доступа
|
||||||
|
|
||||||
|
Начиная с BUILD98 используется один фиксированный PIN конструктора:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PIN[i] = (DDMM[i] + MASK[i]) mod 10
|
1993
|
||||||
```
|
```
|
||||||
|
|
||||||
Маска находится в:
|
Его можно переопределить переменной окружения:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
settings/editor_security.json
|
EDITOR_FIXED_PIN
|
||||||
```
|
```
|
||||||
|
|
||||||
Или может быть переопределена переменными окружения:
|
Старые поля `daily_mask` и `master_pin` в `settings/editor_security.json` сохраняются только для совместимости со старыми конфигурациями и больше не участвуют в проверке входа.
|
||||||
|
|
||||||
|
Дополнительные настройки сессии:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
EDITOR_PIN_MASK
|
|
||||||
EDITOR_MASTER_PIN
|
|
||||||
EDITOR_PIN_TIMEZONE
|
EDITOR_PIN_TIMEZONE
|
||||||
EDITOR_SESSION_MINUTES
|
EDITOR_SESSION_MINUTES
|
||||||
EDITOR_MAX_ATTEMPTS
|
EDITOR_MAX_ATTEMPTS
|
||||||
@@ -27,7 +29,7 @@ EDITOR_LOCK_SECONDS
|
|||||||
|
|
||||||
1. Оператор открывает `/`.
|
1. Оператор открывает `/`.
|
||||||
2. Нажимает `Ctrl+Shift+E`.
|
2. Нажимает `Ctrl+Shift+E`.
|
||||||
3. Вводит ежедневный PIN.
|
3. Вводит PIN `1993`.
|
||||||
4. Редактирует черновик.
|
4. Редактирует черновик.
|
||||||
5. Проверяет через «Предпросмотр».
|
5. Проверяет через «Предпросмотр».
|
||||||
6. Нажимает «Опубликовать».
|
6. Нажимает «Опубликовать».
|
||||||
|
|||||||
4
app.py
4
app.py
@@ -29,7 +29,9 @@ from ui_builder import install_ui_builder
|
|||||||
from khl_site.khl_data_center import APP as khl_site_app
|
from khl_site.khl_data_center import APP as khl_site_app
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
BUILD_VERSION = "2026.08.21.1"
|
BUILD_VERSION = "2026.08.24.2"
|
||||||
|
# compatibility: BUILD_VERSION = "2026.08.24.1"
|
||||||
|
# compatibility: BUILD_VERSION = "2026.08.21.1"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.20.17"
|
# compatibility: BUILD_VERSION = "2026.08.20.17"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.20.16"
|
# compatibility: BUILD_VERSION = "2026.08.20.16"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.20.15"
|
# compatibility: BUILD_VERSION = "2026.08.20.15"
|
||||||
|
|||||||
@@ -2308,6 +2308,96 @@ class VmixAgentHub:
|
|||||||
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
|
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepared_saved_fields(row: VmixPreparedTitle) -> dict[str, dict[str, str]]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(row.field_values_json or "{}")
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
result: dict[str, dict[str, str]] = {}
|
||||||
|
for raw_name, raw_entry in list(payload.items())[:500]:
|
||||||
|
name = str(raw_name or "").strip()[:300]
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if isinstance(raw_entry, dict):
|
||||||
|
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
|
||||||
|
field_type = str(raw_entry.get("type") or "text").strip().lower()
|
||||||
|
else:
|
||||||
|
value = "" if raw_entry is None else str(raw_entry)
|
||||||
|
field_type = "text"
|
||||||
|
if field_type not in {"text", "image", "source", "color", "colour"}:
|
||||||
|
field_type = "text"
|
||||||
|
result[name] = {"value": value[:8000], "type": field_type}
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepared_next_default_name(session: Any, *, user_id: str, device_uuid: str, game_id: str) -> str:
|
||||||
|
query = select(VmixPreparedTitle.name).where(VmixPreparedTitle.wfl_user_id == user_id)
|
||||||
|
if device_uuid:
|
||||||
|
query = query.where(VmixPreparedTitle.device_uuid == device_uuid)
|
||||||
|
if game_id:
|
||||||
|
query = query.where(VmixPreparedTitle.game_external_id == game_id)
|
||||||
|
used: set[int] = set()
|
||||||
|
for raw_name in session.scalars(query):
|
||||||
|
match = re.fullmatch(r"\s*Заготовка\s+(\d+)\s*", str(raw_name or ""), flags=re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
used.add(int(match.group(1)))
|
||||||
|
number = 1
|
||||||
|
while number in used:
|
||||||
|
number += 1
|
||||||
|
return f"Заготовка {number}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepared_field_update_commands(
|
||||||
|
*,
|
||||||
|
input_ref: str,
|
||||||
|
available_fields: list[dict[str, Any]],
|
||||||
|
raw_values: Any,
|
||||||
|
existing_fields: dict[str, dict[str, str]] | None = None,
|
||||||
|
) -> tuple[dict[str, dict[str, str]], list[dict[str, Any]]]:
|
||||||
|
values = raw_values if isinstance(raw_values, dict) else {}
|
||||||
|
saved_fields = dict(existing_fields or {})
|
||||||
|
allowed: dict[str, dict[str, Any]] = {
|
||||||
|
str(field.get("name") or ""): field
|
||||||
|
for field in available_fields
|
||||||
|
if isinstance(field, dict) and str(field.get("name") or "")
|
||||||
|
}
|
||||||
|
# An old Agent inventory may temporarily miss fields of the freshly
|
||||||
|
# cloned Input. Existing saved fields are still safe edit targets.
|
||||||
|
for name, entry in saved_fields.items():
|
||||||
|
allowed.setdefault(name, {"name": name, "type": str(entry.get("type") or "text")})
|
||||||
|
|
||||||
|
commands: list[dict[str, Any]] = []
|
||||||
|
for raw_name, raw_entry in list(values.items())[:240]:
|
||||||
|
name = str(raw_name or "").strip()[:300]
|
||||||
|
field = allowed.get(name)
|
||||||
|
if not name or field is None:
|
||||||
|
continue
|
||||||
|
if isinstance(raw_entry, dict):
|
||||||
|
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
|
||||||
|
requested_type = str(raw_entry.get("type") or "").strip().lower()
|
||||||
|
else:
|
||||||
|
value = "" if raw_entry is None else str(raw_entry)
|
||||||
|
requested_type = ""
|
||||||
|
existing_type = str(saved_fields.get(name, {}).get("type") or "").strip().lower()
|
||||||
|
field_type = str(field.get("type") or requested_type or existing_type or "text").strip().lower()
|
||||||
|
if field_type not in {"text", "image", "source", "color", "colour"}:
|
||||||
|
field_type = requested_type if requested_type in {"text", "image", "source", "color", "colour"} else "text"
|
||||||
|
saved_fields[name] = {"value": value[:8000], "type": field_type}
|
||||||
|
# Empty text deliberately clears inherited text. Some vMix title
|
||||||
|
# engines reject an empty image/source/color, so leave those alone.
|
||||||
|
if not value and field_type in {"image", "source", "color", "colour"}:
|
||||||
|
continue
|
||||||
|
commands.append({
|
||||||
|
"Function": _mapping_value_function(field_type),
|
||||||
|
"Input": input_ref,
|
||||||
|
"SelectedName": name,
|
||||||
|
"Value": value,
|
||||||
|
})
|
||||||
|
return saved_fields, commands
|
||||||
|
|
||||||
async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
|
async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
|
||||||
with self.database.session() as session:
|
with self.database.session() as session:
|
||||||
device = self._prepared_device_for_user(session, user, device_id)
|
device = self._prepared_device_for_user(session, user, device_id)
|
||||||
@@ -2330,7 +2420,12 @@ class VmixAgentHub:
|
|||||||
if found is not None:
|
if found is not None:
|
||||||
row.clone_input_key = str(found.get("key") or row.clone_input_key or "")[:128]
|
row.clone_input_key = str(found.get("key") or row.clone_input_key or "")[:128]
|
||||||
row.clone_input_number = str(found.get("number") or row.clone_input_number or "")[:32]
|
row.clone_input_number = str(found.get("number") or row.clone_input_number or "")[:32]
|
||||||
row.clone_input_title = str(found.get("title") or row.clone_input_title or "")[:300]
|
found_title = str(found.get("title") or "")[:300]
|
||||||
|
# Right after SetInputName the Agent inventory can lag by one
|
||||||
|
# scan. Do not overwrite a freshly stored desired title with
|
||||||
|
# the old inherited source name during that short window.
|
||||||
|
if found_title and (str(row.clone_input_title or "") != str(row.name or "") or found_title == str(row.name or "")):
|
||||||
|
row.clone_input_title = found_title
|
||||||
items = [self._prepared_title_payload(row, inventory) for row in rows]
|
items = [self._prepared_title_payload(row, inventory) for row in rows]
|
||||||
return {
|
return {
|
||||||
"device_id": device.device_uuid,
|
"device_id": device.device_uuid,
|
||||||
@@ -2463,6 +2558,7 @@ class VmixAgentHub:
|
|||||||
session_token = str(getattr(payload, "session_token", "") or "").strip()
|
session_token = str(getattr(payload, "session_token", "") or "").strip()
|
||||||
with self.database.session() as session:
|
with self.database.session() as session:
|
||||||
device = self._prepared_device_for_user(session, user, requested_device)
|
device = self._prepared_device_for_user(session, user, requested_device)
|
||||||
|
current_game_id = str(device.current_match_external_id or "").strip()
|
||||||
inventory = self._prepared_inventory_from_row(device)
|
inventory = self._prepared_inventory_from_row(device)
|
||||||
source = self._prepared_find_input(
|
source = self._prepared_find_input(
|
||||||
inventory,
|
inventory,
|
||||||
@@ -2490,9 +2586,22 @@ class VmixAgentHub:
|
|||||||
timeout=6.0,
|
timeout=6.0,
|
||||||
)
|
)
|
||||||
target_device_id = str(create_result.get("device_id") or device.device_uuid)
|
target_device_id = str(create_result.get("device_id") or device.device_uuid)
|
||||||
match_id = str(create_result.get("match_id") or "")
|
match_id = str(create_result.get("match_id") or current_game_id or "").strip()
|
||||||
|
|
||||||
|
requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
|
||||||
|
if requested_name:
|
||||||
|
title_name = requested_name
|
||||||
|
else:
|
||||||
|
with self.database.session() as session:
|
||||||
|
title_name = self._prepared_next_default_name(
|
||||||
|
session,
|
||||||
|
user_id=user.id,
|
||||||
|
device_uuid=target_device_id,
|
||||||
|
game_id=match_id,
|
||||||
|
)
|
||||||
|
|
||||||
clone: dict[str, Any] | None = None
|
clone: dict[str, Any] | None = None
|
||||||
|
current_inputs: list[dict[str, Any]] = []
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
await asyncio.sleep(0.18)
|
await asyncio.sleep(0.18)
|
||||||
with self.database.session() as session:
|
with self.database.session() as session:
|
||||||
@@ -2505,50 +2614,43 @@ class VmixAgentHub:
|
|||||||
break
|
break
|
||||||
clone_key = str(clone.get("key") or "") if clone else ""
|
clone_key = str(clone.get("key") or "") if clone else ""
|
||||||
clone_number = str(clone.get("number") or expected_number) if clone else expected_number
|
clone_number = str(clone.get("number") or expected_number) if clone else expected_number
|
||||||
clone_title = str(clone.get("title") or source_title) if clone else source_title
|
clone_ref = clone_key or clone_number or str(clone.get("title") or source_title if clone else source_title)
|
||||||
clone_ref = clone_key or clone_number or clone_title
|
|
||||||
|
|
||||||
raw_values = getattr(payload, "field_values", {})
|
raw_values = getattr(payload, "field_values", {})
|
||||||
raw_values = raw_values if isinstance(raw_values, dict) else {}
|
|
||||||
source_fields = source.get("fields") if isinstance(source.get("fields"), list) else []
|
source_fields = source.get("fields") if isinstance(source.get("fields"), list) else []
|
||||||
allowed = {str(field.get("name") or ""): field for field in source_fields if isinstance(field, dict) and str(field.get("name") or "")}
|
saved_fields, field_commands = self._prepared_field_update_commands(
|
||||||
commands: list[dict[str, Any]] = []
|
input_ref=clone_ref,
|
||||||
saved_fields: dict[str, dict[str, str]] = {}
|
available_fields=source_fields,
|
||||||
for name, field in list(allowed.items())[:240]:
|
raw_values=raw_values,
|
||||||
if name not in raw_values:
|
)
|
||||||
continue
|
|
||||||
raw_entry = raw_values.get(name)
|
# BUILD99: the prepared title name is also the actual vMix Input display
|
||||||
if isinstance(raw_entry, dict):
|
# name. vMix exposes SetInputName through the Shortcut/API surface.
|
||||||
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
|
commands: list[dict[str, Any]] = [
|
||||||
requested_type = str(raw_entry.get("type") or "")
|
{"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
|
||||||
else:
|
*field_commands,
|
||||||
value = "" if raw_entry is None else str(raw_entry)
|
]
|
||||||
requested_type = ""
|
|
||||||
field_type = str(field.get("type") or requested_type or "text").lower()
|
# vMix supports selecting categories via API, but does not expose a
|
||||||
if field_type not in {"text", "image", "source", "color", "colour"}:
|
# supported shortcut to create/label a custom category or assign an
|
||||||
field_type = requested_type.lower() if requested_type.lower() in {"text", "image", "source", "color", "colour"} else "text"
|
# Input to it. Keep the safe fallback requested by the operator: the
|
||||||
saved_fields[name] = {"value": value[:8000], "type": field_type}
|
# prepared title stays at the end of the project. CreateVirtualInput
|
||||||
# Empty text is meaningful (clear inherited content). Empty image/color is
|
# normally appends already; MoveInput makes that deterministic if the
|
||||||
# treated as "leave inherited value" because vMix rejects some empty sources.
|
# project reordered while the clone was being discovered.
|
||||||
if not value and field_type in {"image", "source", "color", "colour"}:
|
current_numbers = [int(str(item.get("number") or "")) for item in current_inputs if str(item.get("number") or "").isdigit()]
|
||||||
continue
|
end_position = max(current_numbers) if current_numbers else 0
|
||||||
commands.append({
|
if end_position > 0 and clone_number.isdigit() and int(clone_number) != end_position:
|
||||||
"Function": _mapping_value_function(field_type),
|
commands.append({"Function": "MoveInput", "Input": clone_ref, "Value": str(end_position)})
|
||||||
"Input": clone_ref,
|
clone_number = str(end_position)
|
||||||
"SelectedName": name,
|
|
||||||
"Value": value,
|
set_result = await self.run_vmix_sequence_for_user(
|
||||||
})
|
user,
|
||||||
set_result = None
|
commands,
|
||||||
if commands:
|
device_id=target_device_id,
|
||||||
set_result = await self.run_vmix_sequence_for_user(
|
session_token=session_token,
|
||||||
user,
|
timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
|
||||||
commands,
|
)
|
||||||
device_id=target_device_id,
|
|
||||||
session_token=session_token,
|
|
||||||
timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
|
|
||||||
)
|
|
||||||
|
|
||||||
title_name = str(getattr(payload, "name", "") or "").strip()[:200] or f"{source_title or 'Title'} · заготовка"
|
|
||||||
source_kind = re.sub(r"[^A-Za-z0-9_-]+", "_", str(getattr(payload, "source_kind", "manual") or "manual").strip())[:32] or "manual"
|
source_kind = re.sub(r"[^A-Za-z0-9_-]+", "_", str(getattr(payload, "source_kind", "manual") or "manual").strip())[:32] or "manual"
|
||||||
source_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128]
|
source_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128]
|
||||||
with self.database.session() as session:
|
with self.database.session() as session:
|
||||||
@@ -2564,7 +2666,7 @@ class VmixAgentHub:
|
|||||||
source_input_title=source_title,
|
source_input_title=source_title,
|
||||||
clone_input_key=clone_key,
|
clone_input_key=clone_key,
|
||||||
clone_input_number=clone_number,
|
clone_input_number=clone_number,
|
||||||
clone_input_title=clone_title,
|
clone_input_title=title_name,
|
||||||
field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
|
field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
created_at=_utcnow(),
|
created_at=_utcnow(),
|
||||||
@@ -2575,7 +2677,88 @@ class VmixAgentHub:
|
|||||||
result = self._prepared_title_payload(row)
|
result = self._prepared_title_payload(row)
|
||||||
result["ok"] = True
|
result["ok"] = True
|
||||||
result["create_result"] = create_result
|
result["create_result"] = create_result
|
||||||
result["set_result"] = set_result or {"ok": True, "applied": 0}
|
result["set_result"] = set_result
|
||||||
|
result["placement"] = {
|
||||||
|
"requested_category": "Заготовки",
|
||||||
|
"mode": "end",
|
||||||
|
"category_supported": False,
|
||||||
|
"reason": "vmix_shortcut_api_has_no_input_category_assignment",
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def update_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
|
||||||
|
session_token = str(getattr(payload, "session_token", "") or "").strip()
|
||||||
|
with self.database.session() as session:
|
||||||
|
row = session.get(VmixPreparedTitle, int(prepared_id))
|
||||||
|
if row is None or row.wfl_user_id != user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Заготовка не найдена")
|
||||||
|
requested_device = str(getattr(payload, "device_id", "") or row.device_uuid or "").strip()
|
||||||
|
device = self._prepared_device_for_user(session, user, requested_device)
|
||||||
|
inventory = self._prepared_inventory_from_row(device)
|
||||||
|
clone = self._prepared_find_input(
|
||||||
|
inventory,
|
||||||
|
key=str(row.clone_input_key or ""),
|
||||||
|
number=str(row.clone_input_number or ""),
|
||||||
|
title=str(row.clone_input_title or row.name or ""),
|
||||||
|
)
|
||||||
|
source = self._prepared_find_input(
|
||||||
|
inventory,
|
||||||
|
key=str(row.source_input_key or ""),
|
||||||
|
number=str(row.source_input_number or ""),
|
||||||
|
title=str(row.source_input_title or ""),
|
||||||
|
)
|
||||||
|
clone_ref = str(clone.get("key") or clone.get("number") or "") if clone is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
|
||||||
|
if not clone_ref:
|
||||||
|
raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
|
||||||
|
old_fields = self._prepared_saved_fields(row)
|
||||||
|
available_fields = clone.get("fields") if clone is not None and isinstance(clone.get("fields"), list) else []
|
||||||
|
if not available_fields and source is not None and isinstance(source.get("fields"), list):
|
||||||
|
available_fields = source.get("fields")
|
||||||
|
old_name = str(row.name or "").strip()
|
||||||
|
requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
|
||||||
|
title_name = requested_name or old_name
|
||||||
|
if not title_name:
|
||||||
|
title_name = self._prepared_next_default_name(
|
||||||
|
session,
|
||||||
|
user_id=user.id,
|
||||||
|
device_uuid=str(row.device_uuid or device.device_uuid or ""),
|
||||||
|
game_id=str(row.game_external_id or device.current_match_external_id or ""),
|
||||||
|
)
|
||||||
|
saved_fields, field_commands = self._prepared_field_update_commands(
|
||||||
|
input_ref=clone_ref,
|
||||||
|
available_fields=available_fields,
|
||||||
|
raw_values=getattr(payload, "field_values", {}),
|
||||||
|
existing_fields=old_fields,
|
||||||
|
)
|
||||||
|
target_device_id = device.device_uuid
|
||||||
|
|
||||||
|
commands: list[dict[str, Any]] = [
|
||||||
|
{"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
|
||||||
|
*field_commands,
|
||||||
|
]
|
||||||
|
set_result = await self.run_vmix_sequence_for_user(
|
||||||
|
user,
|
||||||
|
commands,
|
||||||
|
device_id=target_device_id,
|
||||||
|
session_token=session_token,
|
||||||
|
timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.database.session() as session:
|
||||||
|
row = session.get(VmixPreparedTitle, int(prepared_id))
|
||||||
|
if row is None or row.wfl_user_id != user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Заготовка не найдена")
|
||||||
|
row.name = title_name
|
||||||
|
row.clone_input_title = title_name[:300]
|
||||||
|
if clone is not None:
|
||||||
|
row.clone_input_key = str(clone.get("key") or row.clone_input_key or "")[:128]
|
||||||
|
row.clone_input_number = str(clone.get("number") or row.clone_input_number or "")[:32]
|
||||||
|
row.field_values_json = json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
row.updated_at = _utcnow()
|
||||||
|
result = self._prepared_title_payload(row)
|
||||||
|
result["ok"] = True
|
||||||
|
result["set_result"] = set_result
|
||||||
|
result["updated_existing_input"] = True
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
|
async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
|
||||||
@@ -3798,6 +3981,13 @@ class PreparedTitleCreatePayload(BaseModel):
|
|||||||
field_values: dict[str, Any] = Field(default_factory=dict)
|
field_values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PreparedTitleUpdatePayload(BaseModel):
|
||||||
|
name: str = Field(default="", max_length=200)
|
||||||
|
device_id: str = Field(default="", max_length=128)
|
||||||
|
session_token: str = Field(default="", max_length=128)
|
||||||
|
field_values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class PreparedTitlePreviewPayload(BaseModel):
|
class PreparedTitlePreviewPayload(BaseModel):
|
||||||
device_id: str = Field(default="", max_length=128)
|
device_id: str = Field(default="", max_length=128)
|
||||||
session_token: str = Field(default="", max_length=128)
|
session_token: str = Field(default="", max_length=128)
|
||||||
@@ -3871,6 +4061,14 @@ def create_hockey_agent_router(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return await hub.create_prepared_title(user, payload)
|
return await hub.create_prepared_title(user, payload)
|
||||||
|
|
||||||
|
@router.put("/api/hockey/prepared-titles/{prepared_id}")
|
||||||
|
async def prepared_title_update(
|
||||||
|
prepared_id: int,
|
||||||
|
payload: PreparedTitleUpdatePayload,
|
||||||
|
user: HockeyUser = Depends(auth_dependency),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await hub.update_prepared_title(prepared_id, user, payload)
|
||||||
|
|
||||||
@router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
|
@router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
|
||||||
async def prepared_title_preview(
|
async def prepared_title_preview(
|
||||||
prepared_id: int,
|
prepared_id: int,
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ APP = (ROOT / "app.py").read_text(encoding="utf-8")
|
|||||||
def test_game_countdown_resume_is_reseeded_before_start():
|
def test_game_countdown_resume_is_reseeded_before_start():
|
||||||
block = APP_JS.split("async function syncActiveVmixGameCountdown", 1)[1].split("function penaltyMirrorKey", 1)[0]
|
block = APP_JS.split("async function syncActiveVmixGameCountdown", 1)[1].split("function penaltyMirrorKey", 1)[0]
|
||||||
assert '["timer_start", "timer_restart", "timer_resume"].includes(eventName)' in block
|
assert '["timer_start", "timer_restart", "timer_resume"].includes(eventName)' in block
|
||||||
set_pos = block.index('Function: "SetCountdown"')
|
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")' in block
|
||||||
start_pos = block.index('Function: "StartCountdown"')
|
helper = APP_JS.split("function vmixCountdownSyncCommands", 1)[1].split("function buildShortcutRuntimeContext", 1)[0]
|
||||||
assert set_pos < start_pos
|
start_branch = helper.rsplit("return [", 1)[1]
|
||||||
|
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
||||||
assert 'eventName === "timer_resume"' not in block
|
assert 'eventName === "timer_resume"' not in block
|
||||||
|
|
||||||
|
|
||||||
@@ -18,8 +19,9 @@ def test_hockey_timer_shortcut_reseeds_game_and_penalty_on_resume():
|
|||||||
block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0]
|
block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0]
|
||||||
# Resume must no longer have a StartCountdown-only branch.
|
# Resume must no longer have a StartCountdown-only branch.
|
||||||
assert '} else if (action === "resume") {' not in block
|
assert '} else if (action === "resume") {' not in block
|
||||||
assert block.count('Function: "SetCountdown"') >= 2
|
assert block.count('vmixCountdownSyncCommands(') >= 2
|
||||||
assert block.count('Function: "StartCountdown"') >= 2
|
assert '() => gameTimerState.currentMs' in block
|
||||||
|
assert '() => event.remainingMs' in block
|
||||||
|
|
||||||
|
|
||||||
def test_penalty_rebalance_pairs_every_start_with_current_web_value():
|
def test_penalty_rebalance_pairs_every_start_with_current_web_value():
|
||||||
|
|||||||
@@ -27,20 +27,21 @@ def test_penalty_vmix_display_uses_only_started_or_paused_active_penalties():
|
|||||||
|
|
||||||
def test_main_countdown_start_pause_stop_all_hard_sync_web_value():
|
def test_main_countdown_start_pause_stop_all_hard_sync_web_value():
|
||||||
block = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey")
|
block = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey")
|
||||||
start = block.split('if (["timer_start", "timer_restart", "timer_resume"].includes(eventName))', 1)[1].split('} else if (eventName === "timer_pause")', 1)[0]
|
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")' in block
|
||||||
assert start.index('Function: "SetCountdown"') < start.index('Function: "StartCountdown"')
|
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause")' in block
|
||||||
pause = block.split('eventName === "timer_pause"', 1)[1].split('} else if (["timer_stop", "timer_finished"]', 1)[0]
|
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")' in block
|
||||||
assert pause.index('Function: "PauseCountdown"') < pause.index('Function: "SetCountdown"')
|
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||||
stop = block.split('["timer_stop", "timer_finished"].includes(eventName)', 1)[1].split('} else if (["timer_reset"', 1)[0]
|
start_branch = helper.rsplit("return [", 1)[1]
|
||||||
assert stop.index('Function: "StopCountdown"') < stop.index('Function: "SetCountdown"')
|
assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"')
|
||||||
|
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
||||||
|
|
||||||
|
|
||||||
def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run():
|
def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run():
|
||||||
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
||||||
assert 'stopCommands.push({ Function: "PauseCountdown"' in block
|
assert 'stopCommands.push({ Function: "SuspendCountdown"' in block
|
||||||
assert 'setCommands.push({ Function: "SetCountdown"' in block
|
assert 'setCommands.push({ Function: "SetCountdown"' in block
|
||||||
assert 'runCommands.push({ Function: "StartCountdown"' in block
|
assert 'runCommands.push({ Function: "StartCountdown"' in block
|
||||||
assert "Never StartCountdown for a prepared item" in block
|
assert "prepared/paused penalty" in block
|
||||||
|
|
||||||
|
|
||||||
def test_combined_shortcut_changes_web_first_then_synchronizes_vmix():
|
def test_combined_shortcut_changes_web_first_then_synchronizes_vmix():
|
||||||
@@ -48,8 +49,9 @@ def test_combined_shortcut_changes_web_first_then_synchronizes_vmix():
|
|||||||
web_game = block.index("if (step.start_web_game)")
|
web_game = block.index("if (step.start_web_game)")
|
||||||
vmix_game = block.index("if (step.sync_vmix_game && step.game_vmix_input)")
|
vmix_game = block.index("if (step.sync_vmix_game && step.game_vmix_input)")
|
||||||
assert web_game < vmix_game
|
assert web_game < vmix_game
|
||||||
assert block.index('Function: "PauseCountdown"') < block.index('Function: "SetCountdown"', block.index('Function: "PauseCountdown"'))
|
assert 'vmixCountdownSyncCommands(' in block
|
||||||
assert 'Function: "StartCountdown"' in block
|
assert '() => gameTimerState.currentMs' in block
|
||||||
|
assert '() => event.remainingMs' in block
|
||||||
|
|
||||||
|
|
||||||
def test_build95_runtime_version():
|
def test_build95_runtime_version():
|
||||||
|
|||||||
67
tests/test_build98_timer_countdown_sync_fixed_pin.py
Normal file
67
tests/test_build98_timer_countdown_sync_fixed_pin.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from ui_builder.auth import EditorAuthManager
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
|
APP = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||||
|
AUTH = (ROOT / "ui_builder/auth.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _block(start: str, end: str) -> str:
|
||||||
|
return APP_JS.split(start, 1)[1].split(end, 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vmix_queue_resolves_countdown_values_at_actual_send_time():
|
||||||
|
compact = _block("function compactVmixCommand", "function currentRuntimeVmixDeviceId")
|
||||||
|
assert 'typeof rawValue === "function" ? rawValue() : rawValue' in compact
|
||||||
|
sender = _block("async function sendRuntimeVmixSequence", "function splitVmixInputs")
|
||||||
|
assert 'const rawCommands = typeof commands === "function" ? commands() : commands;' in sender
|
||||||
|
assert 'const clean = (rawCommands || []).map(compactVmixCommand)' in sender
|
||||||
|
|
||||||
|
|
||||||
|
def test_countdown_start_is_deterministic_suspend_set_start():
|
||||||
|
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||||
|
start_branch = helper.split('return [', 3)[-1]
|
||||||
|
assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"')
|
||||||
|
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
||||||
|
assert 'Value: currentValue' in helper
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_uses_suspend_not_toggle_pausecountdown():
|
||||||
|
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||||
|
pause = helper.split('if (action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0]
|
||||||
|
assert 'Function: "SuspendCountdown"' in pause
|
||||||
|
assert 'Function: "PauseCountdown"' not in pause
|
||||||
|
assert pause.index('Function: "SuspendCountdown"') < pause.index('Function: "SetCountdown"')
|
||||||
|
|
||||||
|
|
||||||
|
def test_penalty_rebalance_hard_syncs_running_countdown_and_pauses_stably():
|
||||||
|
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
||||||
|
assert 'stopCommands.push({ Function: "SuspendCountdown"' in block
|
||||||
|
assert 'Value: () => vmixCountdownValue(entry.event.remainingMs)' in block
|
||||||
|
assert 'runCommands.push({ Function: "StartCountdown"' in block
|
||||||
|
assert 'Function: "PauseCountdown"' not in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_penalty_is_preferred_over_paused_shorter_penalty():
|
||||||
|
sorted_block = _block("function sortedPenaltyEntries", "function penaltyLocalAdvantageSide")
|
||||||
|
assert 'const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));' in sorted_block
|
||||||
|
display = _block("function penaltyDisplayEntriesByTargetSide", "function rememberPenaltyAdvantagePlan")
|
||||||
|
assert 'const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));' in display
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixed_pin_1993_replaces_daily_login(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("EDITOR_FIXED_PIN", raising=False)
|
||||||
|
auth = EditorAuthManager(tmp_path)
|
||||||
|
assert auth.settings["fixed_pin"] == "1993"
|
||||||
|
# The old daily algorithm remains diagnostic-only; for 24.08.2026 its
|
||||||
|
# default result is 6225, but login validation no longer uses it.
|
||||||
|
assert auth.daily_pin(datetime(2026, 8, 24, 12, 0, tzinfo=ZoneInfo("Europe/Moscow"))) == "6225"
|
||||||
|
assert 'valid = hmac.compare_digest(supplied, str(self.settings["fixed_pin"]))' in AUTH
|
||||||
|
|
||||||
|
|
||||||
|
def test_build98_runtime_version():
|
||||||
|
assert 'BUILD_VERSION = "2026.08.24.1"' in APP
|
||||||
95
tests/test_build99_prepared_title_editing.py
Normal file
95
tests/test_build99_prepared_title_editing.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from hockey_data.agent_bridge import VmixAgentHub
|
||||||
|
from hockey_data.auth_bridge import HockeyUser
|
||||||
|
from hockey_data.models import VmixDevice, VmixPreparedTitle
|
||||||
|
from tests.support import LocalTestDatabase
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
|
BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
|
||||||
|
APP = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build99_frontend_and_routes_exist():
|
||||||
|
assert 'preparedTitleEditingId: ""' in APP_JS
|
||||||
|
assert 'data-prepared-edit=' in APP_JS
|
||||||
|
assert 'method: "PUT"' in APP_JS
|
||||||
|
assert '@router.put("/api/hockey/prepared-titles/{prepared_id}")' in BRIDGE
|
||||||
|
assert '"Function": "SetInputName"' in BRIDGE
|
||||||
|
assert 'return f"Заготовка {number}"' in BRIDGE
|
||||||
|
assert 'BUILD_VERSION = "2026.08.24.2"' in APP
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_name_gets_number_and_existing_clone_is_editable(tmp_path):
|
||||||
|
database = LocalTestDatabase(tmp_path / "prepared99.sqlite3")
|
||||||
|
database.create_all()
|
||||||
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
|
user = HockeyUser(id="prep99", login="prep99", display_name="Prep99")
|
||||||
|
source = {
|
||||||
|
"key": "source-key",
|
||||||
|
"number": "12",
|
||||||
|
"title": "COMPARE",
|
||||||
|
"type": "GT",
|
||||||
|
"fields": [{"name": "Name.Text", "type": "text", "index": "0"}],
|
||||||
|
}
|
||||||
|
inventory = {"inputs": [source]}
|
||||||
|
with database.session() as session:
|
||||||
|
session.add(VmixDevice(
|
||||||
|
device_uuid="prep-device",
|
||||||
|
device_secret_hash="x" * 64,
|
||||||
|
name="Prep device",
|
||||||
|
wfl_user_id=user.id,
|
||||||
|
is_active_for_account=True,
|
||||||
|
vmix_connected=True,
|
||||||
|
current_match_external_id="9001",
|
||||||
|
project_inventory_json=json.dumps(inventory),
|
||||||
|
))
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_sequence(self, _user, commands, *, device_id="", session_token="", timeout=0, **kwargs):
|
||||||
|
calls.append(commands)
|
||||||
|
if commands and commands[0].get("Function") == "CreateVirtualInput":
|
||||||
|
cloned = {"inputs": [source, {**source, "key": "clone-key", "number": "13", "title": "COMPARE"}]}
|
||||||
|
with database.session() as session:
|
||||||
|
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "prep-device"))
|
||||||
|
device.project_inventory_json = json.dumps(cloned)
|
||||||
|
return {"ok": True, "device_id": device_id or "prep-device", "match_id": "9001"}
|
||||||
|
|
||||||
|
hub.run_vmix_sequence_for_user = types.MethodType(fake_sequence, hub)
|
||||||
|
created = asyncio.run(hub.create_prepared_title(user, SimpleNamespace(
|
||||||
|
name="",
|
||||||
|
device_id="prep-device",
|
||||||
|
session_token="",
|
||||||
|
source_input_key="source-key",
|
||||||
|
source_input_number="12",
|
||||||
|
source_input_title="COMPARE",
|
||||||
|
source_kind="manual",
|
||||||
|
source_ref="",
|
||||||
|
field_values={"Name.Text": {"type": "text", "value": "Иванов"}},
|
||||||
|
)))
|
||||||
|
assert created["name"] == "Заготовка 1"
|
||||||
|
assert created["clone_input"]["title"] == "Заготовка 1"
|
||||||
|
assert any(c.get("Function") == "SetInputName" and c.get("Value") == "Заготовка 1" for c in calls[1])
|
||||||
|
|
||||||
|
updated = asyncio.run(hub.update_prepared_title(created["id"], user, SimpleNamespace(
|
||||||
|
name="Сравнение вратарей",
|
||||||
|
device_id="prep-device",
|
||||||
|
session_token="",
|
||||||
|
field_values={"Name.Text": {"type": "text", "value": "Петров"}},
|
||||||
|
)))
|
||||||
|
assert updated["name"] == "Сравнение вратарей"
|
||||||
|
assert updated["field_values"]["Name.Text"]["value"] == "Петров"
|
||||||
|
assert any(c.get("Function") == "SetInputName" and c.get("Value") == "Сравнение вратарей" for c in calls[-1])
|
||||||
|
with database.session() as session:
|
||||||
|
row = session.get(VmixPreparedTitle, created["id"])
|
||||||
|
assert row is not None
|
||||||
|
assert row.name == "Сравнение вратарей"
|
||||||
|
assert row.clone_input_title == "Сравнение вратарей"
|
||||||
@@ -106,7 +106,7 @@ def test_shortcut_editor_layout_fixes_add_step_buttons_and_fullscreen_modal() ->
|
|||||||
|
|
||||||
def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None:
|
def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None:
|
||||||
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
assert 'Function: "PauseCountdown"' in app_js
|
assert 'Function: "SuspendCountdown"' in app_js
|
||||||
assert 'Function: "StopCountdown"' in app_js
|
assert 'Function: "StopCountdown"' in app_js
|
||||||
assert 'hockey_timer_command' in app_js
|
assert 'hockey_timer_command' in app_js
|
||||||
assert 'game_vmix_mode' in app_js
|
assert 'game_vmix_mode' in app_js
|
||||||
@@ -183,8 +183,11 @@ def test_hockey_timer_targets_persist_selected_names_and_finish_actions(tmp_path
|
|||||||
def test_hockey_countdown_uses_selected_name_for_game_and_penalty_targets() -> None:
|
def test_hockey_countdown_uses_selected_name_for_game_and_penalty_targets() -> None:
|
||||||
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert 'Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name' in app_js
|
assert 'function vmixCountdownSyncCommands(input, selectedName' in app_js
|
||||||
assert 'Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name' in app_js
|
assert 'SelectedName: String(selectedName || "").trim()' in app_js
|
||||||
|
assert 'step.game_vmix_selected_name, () => gameTimerState.currentMs' in app_js
|
||||||
|
assert 'target.input, target.selected_name, () => event.remainingMs' in app_js
|
||||||
|
assert 'Input: target.input, SelectedName: target.selected_name' in app_js
|
||||||
assert 'vmixTextSelectedNameOptions' in app_js
|
assert 'vmixTextSelectedNameOptions' in app_js
|
||||||
assert 'data-penalty-target-field="selected_name"' in app_js
|
assert 'data-penalty-target-field="selected_name"' in app_js
|
||||||
assert 'data-finish-action-add' in app_js
|
assert 'data-finish-action-add' in app_js
|
||||||
@@ -249,7 +252,7 @@ def test_build90_native_countdown_avoids_per_second_transport() -> None:
|
|||||||
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
assert 'game_vmix_mode !== "countdown"' in app_js
|
assert 'game_vmix_mode !== "countdown"' in app_js
|
||||||
assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js
|
assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js
|
||||||
assert 'Function: "PauseCountdown"' in app_js
|
assert 'Function: "SuspendCountdown"' in app_js
|
||||||
assert 'Function: "StopCountdown"' in app_js
|
assert 'Function: "StopCountdown"' in app_js
|
||||||
assert 'signal: controller.signal' in app_js
|
assert 'signal: controller.signal' in app_js
|
||||||
assert 'vmixCommandQueue: Promise.resolve()' in app_js
|
assert 'vmixCommandQueue: Promise.resolve()' in app_js
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_SECURITY: dict[str, Any] = {
|
DEFAULT_SECURITY: dict[str, Any] = {
|
||||||
|
# BUILD98: editor access is a fixed local PIN. The older daily PIN and
|
||||||
|
# master PIN fields are retained only so existing config files still parse.
|
||||||
|
"fixed_pin": "1993",
|
||||||
"daily_mask": "4827",
|
"daily_mask": "4827",
|
||||||
"master_pin": "638194",
|
"master_pin": "638194",
|
||||||
"timezone": "Europe/Moscow",
|
"timezone": "Europe/Moscow",
|
||||||
@@ -43,11 +46,9 @@ class FailureState:
|
|||||||
class EditorAuthManager:
|
class EditorAuthManager:
|
||||||
"""Local PIN guard for the visual editor.
|
"""Local PIN guard for the visual editor.
|
||||||
|
|
||||||
Daily PIN:
|
BUILD98 uses one fixed PIN (1993 by default). The old daily-PIN helpers are
|
||||||
digits(DDMM) + digits(mask), each result modulo 10.
|
kept for backward-compatible diagnostics only and are no longer accepted
|
||||||
|
by the login endpoint.
|
||||||
Example:
|
|
||||||
1607 + 4827 = 5424
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, settings_dir: Path) -> None:
|
def __init__(self, settings_dir: Path) -> None:
|
||||||
@@ -76,6 +77,7 @@ class EditorAuthManager:
|
|||||||
result.update(raw)
|
result.update(raw)
|
||||||
|
|
||||||
overrides = {
|
overrides = {
|
||||||
|
"fixed_pin": os.getenv("EDITOR_FIXED_PIN"),
|
||||||
"daily_mask": os.getenv("EDITOR_PIN_MASK"),
|
"daily_mask": os.getenv("EDITOR_PIN_MASK"),
|
||||||
"master_pin": os.getenv("EDITOR_MASTER_PIN"),
|
"master_pin": os.getenv("EDITOR_MASTER_PIN"),
|
||||||
"timezone": os.getenv("EDITOR_PIN_TIMEZONE"),
|
"timezone": os.getenv("EDITOR_PIN_TIMEZONE"),
|
||||||
@@ -93,6 +95,9 @@ class EditorAuthManager:
|
|||||||
"1", "true", "yes", "on"
|
"1", "true", "yes", "on"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fixed = "".join(character for character in str(result.get("fixed_pin", "1993")) if character.isdigit())
|
||||||
|
result["fixed_pin"] = fixed if 4 <= len(fixed) <= 12 else "1993"
|
||||||
|
|
||||||
mask = "".join(character for character in str(result["daily_mask"]) if character.isdigit())
|
mask = "".join(character for character in str(result["daily_mask"]) if character.isdigit())
|
||||||
result["daily_mask"] = mask[:4].ljust(4, "0") if mask else "4827"
|
result["daily_mask"] = mask[:4].ljust(4, "0") if mask else "4827"
|
||||||
|
|
||||||
@@ -217,10 +222,7 @@ class EditorAuthManager:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
valid = (
|
valid = hmac.compare_digest(supplied, str(self.settings["fixed_pin"]))
|
||||||
hmac.compare_digest(supplied, self.daily_pin())
|
|
||||||
or hmac.compare_digest(supplied, str(self.settings["master_pin"]))
|
|
||||||
)
|
|
||||||
if not valid:
|
if not valid:
|
||||||
failure.attempts += 1
|
failure.attempts += 1
|
||||||
remaining = max(
|
remaining = max(
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
preparedTitleSourceKey: "",
|
preparedTitleSourceKey: "",
|
||||||
preparedTitleFieldValues: {},
|
preparedTitleFieldValues: {},
|
||||||
preparedTitleName: "",
|
preparedTitleName: "",
|
||||||
|
preparedTitleEditingId: "",
|
||||||
preparedTitlePanelId: "",
|
preparedTitlePanelId: "",
|
||||||
preparedTitleMappingSources: [],
|
preparedTitleMappingSources: [],
|
||||||
preparedTitleSnapshotLoading: false,
|
preparedTitleSnapshotLoading: false,
|
||||||
@@ -4134,6 +4135,36 @@ function startCustomTooltips() {
|
|||||||
return `${padTimer(hours)}:${padTimer(minutes)}:${padTimer(seconds)}`;
|
return `${padTimer(hours)}:${padTimer(minutes)}:${padTimer(seconds)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") {
|
||||||
|
const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
|
||||||
|
const currentValue = () => {
|
||||||
|
const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider;
|
||||||
|
return vmixCountdownValue(milliseconds);
|
||||||
|
};
|
||||||
|
if (!target.Input || !target.SelectedName) return [];
|
||||||
|
if (action === "stop") {
|
||||||
|
return [
|
||||||
|
{ Function: "StopCountdown", ...target },
|
||||||
|
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (action === "pause") {
|
||||||
|
// vMix PauseCountdown is a toggle (pause/resume). SuspendCountdown is the
|
||||||
|
// deterministic pause-only command, so it cannot accidentally resume a timer.
|
||||||
|
return [
|
||||||
|
{ Function: "SuspendCountdown", ...target },
|
||||||
|
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
// Hard-sync every launch: freeze any stale title countdown first, sample
|
||||||
|
// Runtime at actual send time, then start from exactly that value.
|
||||||
|
{ Function: "SuspendCountdown", ...target },
|
||||||
|
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||||
|
{ Function: "StartCountdown", ...target },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function buildShortcutRuntimeContext(sequence = null) {
|
function buildShortcutRuntimeContext(sequence = null) {
|
||||||
const timers = {};
|
const timers = {};
|
||||||
state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => {
|
state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => {
|
||||||
@@ -4213,8 +4244,13 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function compactVmixCommand(command) {
|
function compactVmixCommand(command) {
|
||||||
|
// BUILD98: countdown values may be supplied as functions. They are resolved
|
||||||
|
// only when the command actually reaches the front of the browser vMix queue,
|
||||||
|
// so a delayed Agent/ACK cannot make vMix start several seconds behind Runtime.
|
||||||
|
const source = typeof command === "function" ? command() : command;
|
||||||
const result = {};
|
const result = {};
|
||||||
Object.entries(command || {}).forEach(([key, value]) => {
|
Object.entries(source || {}).forEach(([key, rawValue]) => {
|
||||||
|
const value = typeof rawValue === "function" ? rawValue() : rawValue;
|
||||||
if (value === null || value === undefined) return;
|
if (value === null || value === undefined) return;
|
||||||
if (key !== "Value" && String(value) === "") return;
|
if (key !== "Value" && String(value) === "") return;
|
||||||
result[key] = value;
|
result[key] = value;
|
||||||
@@ -4522,10 +4558,13 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendRuntimeVmixSequence(commands, execution = null) {
|
async function sendRuntimeVmixSequence(commands, execution = null) {
|
||||||
const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function);
|
// Do not resolve dynamic command values here. A sequence can sit behind a
|
||||||
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
// previous Agent request for a few seconds; countdowns must be sampled at
|
||||||
|
// the instant this queued request is really sent.
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
|
const rawCommands = typeof commands === "function" ? commands() : commands;
|
||||||
|
const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function);
|
||||||
|
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
||||||
state.vmixCommandQueueDepth += 1;
|
state.vmixCommandQueueDepth += 1;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), 7000);
|
const timeoutId = window.setTimeout(() => controller.abort(), 7000);
|
||||||
@@ -4621,26 +4660,17 @@ function startCustomTooltips() {
|
|||||||
const input = String(step.game_vmix_input || "").trim();
|
const input = String(step.game_vmix_input || "").trim();
|
||||||
const selectedName = String(step.game_vmix_selected_name || "").trim();
|
const selectedName = String(step.game_vmix_selected_name || "").trim();
|
||||||
if (!input || !selectedName) continue;
|
if (!input || !selectedName) continue;
|
||||||
const target = { Input: input, SelectedName: selectedName };
|
const valueProvider = () => timerState.currentMs;
|
||||||
if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) {
|
if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) {
|
||||||
// BUILD95: every launch/resume is a hard Runtime -> vMix sync.
|
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "start"));
|
||||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
|
||||||
commands.push({ Function: "StartCountdown", ...target });
|
|
||||||
} else if (eventName === "timer_pause") {
|
} else if (eventName === "timer_pause") {
|
||||||
// Freeze vMix first, then overwrite it with the exact web value.
|
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause"));
|
||||||
commands.push({ Function: "PauseCountdown", ...target });
|
|
||||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
|
||||||
} else if (["timer_stop", "timer_finished"].includes(eventName)) {
|
} else if (["timer_stop", "timer_finished"].includes(eventName)) {
|
||||||
commands.push({ Function: "StopCountdown", ...target });
|
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop"));
|
||||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
|
||||||
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
|
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
|
||||||
if (timerState.running) {
|
commands.push(...vmixCountdownSyncCommands(
|
||||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
input, selectedName, valueProvider, timerState.running ? "start" : "pause"
|
||||||
commands.push({ Function: "StartCountdown", ...target });
|
));
|
||||||
} else {
|
|
||||||
commands.push({ Function: "PauseCountdown", ...target });
|
|
||||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!commands.length) return false;
|
if (!commands.length) return false;
|
||||||
@@ -4648,7 +4678,6 @@ function startCustomTooltips() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function penaltyMirrorKey(component, event) {
|
function penaltyMirrorKey(component, event) {
|
||||||
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
|
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
|
||||||
}
|
}
|
||||||
@@ -4713,8 +4742,15 @@ function startCustomTooltips() {
|
|||||||
function sortedPenaltyEntries(side = "") {
|
function sortedPenaltyEntries(side = "") {
|
||||||
return activeHockeyPenaltyEntries()
|
return activeHockeyPenaltyEntries()
|
||||||
.filter((item) => !side || item.side === side)
|
.filter((item) => !side || item.side === side)
|
||||||
.sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|
.sort((a, b) => {
|
||||||
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
// BUILD98: a paused penalty cannot be the next real strength transition
|
||||||
|
// while another active penalty is actually running. Prefer running
|
||||||
|
// clocks; only fall back to paused ones when every active clock is paused.
|
||||||
|
const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));
|
||||||
|
if (runningOrder) return runningOrder;
|
||||||
|
return Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|
||||||
|
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// BUILD89: the scorebug has only ONE penalty/power-play plate at a time.
|
// BUILD89: the scorebug has only ONE penalty/power-play plate at a time.
|
||||||
@@ -4758,8 +4794,12 @@ function startCustomTooltips() {
|
|||||||
function penaltyDisplayEntriesByTargetSide(step) {
|
function penaltyDisplayEntriesByTargetSide(step) {
|
||||||
const home = sortedPenaltyEntries("home");
|
const home = sortedPenaltyEntries("home");
|
||||||
const away = sortedPenaltyEntries("away");
|
const away = sortedPenaltyEntries("away");
|
||||||
const all = [...home, ...away].sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|
const all = [...home, ...away].sort((a, b) => {
|
||||||
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));
|
||||||
|
if (runningOrder) return runningOrder;
|
||||||
|
return Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|
||||||
|
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0);
|
||||||
|
});
|
||||||
if (!all.length) {
|
if (!all.length) {
|
||||||
return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry: null };
|
return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry: null };
|
||||||
}
|
}
|
||||||
@@ -4946,20 +4986,21 @@ function startCustomTooltips() {
|
|||||||
});
|
});
|
||||||
const startingCountdown = Boolean(entry.event.running)
|
const startingCountdown = Boolean(entry.event.running)
|
||||||
&& (force || assignmentChanged || previous?.running !== true);
|
&& (force || assignmentChanged || previous?.running !== true);
|
||||||
// BUILD93 invariant: whenever StartCountdown is emitted, SetCountdown with
|
|
||||||
// the current web value is emitted immediately before it.
|
|
||||||
if (entry.event.running) {
|
if (entry.event.running) {
|
||||||
if (force || assignmentChanged || startingCountdown) {
|
if (force || assignmentChanged || startingCountdown) {
|
||||||
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
|
// BUILD98: deterministic hard sync. Suspend is pause-only in vMix;
|
||||||
|
// PauseCountdown is a toggle and could accidentally resume a stale timer.
|
||||||
|
stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
|
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
|
||||||
}
|
}
|
||||||
if (startingCountdown) {
|
if (startingCountdown) {
|
||||||
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
}
|
}
|
||||||
} else if (force || assignmentChanged || previous?.running !== false) {
|
} else if (force || assignmentChanged || previous?.running !== false) {
|
||||||
// BUILD95: on every pause/stop, freeze first and then seed the
|
// Freeze the title and seed the exact Runtime value, but never run a
|
||||||
// exact Runtime value. Never StartCountdown for a prepared item.
|
// prepared/paused penalty until the web event itself is running.
|
||||||
stopCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
|
stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
|
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assignedMirrorKeys.add(eventKey);
|
assignedMirrorKeys.add(eventKey);
|
||||||
@@ -5147,12 +5188,13 @@ function startCustomTooltips() {
|
|||||||
setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name);
|
setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name);
|
||||||
commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
|
commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
|
||||||
} else if (pausing) {
|
} else if (pausing) {
|
||||||
commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
|
commands.push(...vmixCountdownSyncCommands(
|
||||||
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
|
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause"
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
// BUILD95: every start/resume is SetCountdown -> StartCountdown.
|
commands.push(...vmixCountdownSyncCommands(
|
||||||
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
|
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start"
|
||||||
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5176,17 +5218,14 @@ function startCustomTooltips() {
|
|||||||
});
|
});
|
||||||
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
|
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
|
||||||
} else {
|
} else {
|
||||||
|
const eventRunning = Boolean(event.running);
|
||||||
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
|
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
|
||||||
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: !pausing,
|
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: eventRunning,
|
||||||
});
|
});
|
||||||
if (pausing) {
|
commands.push(...vmixCountdownSyncCommands(
|
||||||
commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
|
target.input, target.selected_name, () => event.remainingMs,
|
||||||
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
|
(pausing || !eventRunning) ? "pause" : "start"
|
||||||
} else {
|
));
|
||||||
// BUILD95: every penalty launch/resume is re-seeded from the web timer first.
|
|
||||||
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
|
|
||||||
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -9337,9 +9376,10 @@ async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true
|
|||||||
|
|
||||||
function hockeySelectPreparedSource(input, { clearPanel = false } = {}) {
|
function hockeySelectPreparedSource(input, { clearPanel = false } = {}) {
|
||||||
if (!input) return false;
|
if (!input) return false;
|
||||||
|
state.preparedTitleEditingId = "";
|
||||||
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input);
|
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input);
|
||||||
hockeyPreparedFieldValuesForInput(input, { preserve: false });
|
hockeyPreparedFieldValuesForInput(input, { preserve: false });
|
||||||
if (!state.preparedTitleName) state.preparedTitleName = `${String(input.title || `Input ${input.number || ""}`).trim()} · заготовка`;
|
state.preparedTitleName = "";
|
||||||
if (clearPanel) {
|
if (clearPanel) {
|
||||||
state.preparedTitlePanelId = "";
|
state.preparedTitlePanelId = "";
|
||||||
state.preparedTitleMappingSources = [];
|
state.preparedTitleMappingSources = [];
|
||||||
@@ -9394,7 +9434,7 @@ async function hockeyCreatePreparedTitle() {
|
|||||||
toast("Сначала выберите исходный vMix Input", true);
|
toast("Сначала выберите исходный vMix Input", true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const name = String(state.preparedTitleName || "").trim() || `${input.title || `Input ${input.number || ""}`} · заготовка`;
|
const name = String(state.preparedTitleName || "").trim();
|
||||||
const fieldValues = {};
|
const fieldValues = {};
|
||||||
Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => {
|
Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => {
|
||||||
if (!entry?.touched) return;
|
if (!entry?.touched) return;
|
||||||
@@ -9423,6 +9463,7 @@ async function hockeyCreatePreparedTitle() {
|
|||||||
});
|
});
|
||||||
toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`);
|
toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`);
|
||||||
state.preparedTitleName = "";
|
state.preparedTitleName = "";
|
||||||
|
state.preparedTitleEditingId = "";
|
||||||
state.preparedTitlePanelId = "";
|
state.preparedTitlePanelId = "";
|
||||||
state.preparedTitleMappingSources = [];
|
state.preparedTitleMappingSources = [];
|
||||||
state.preparedTitlesLoadedKey = "";
|
state.preparedTitlesLoadedKey = "";
|
||||||
@@ -9437,6 +9478,90 @@ async function hockeyCreatePreparedTitle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hockeyPreparedInventoryMatch(ref = {}) {
|
||||||
|
const inputs = hockeyPreparedInventoryInputs();
|
||||||
|
return inputs.find((item) =>
|
||||||
|
(ref?.key && String(item.key || "") === String(ref.key)) ||
|
||||||
|
(ref?.number && String(item.number || "") === String(ref.number)) ||
|
||||||
|
(ref?.title && String(item.title || "") === String(ref.title))
|
||||||
|
) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyBeginEditPreparedTitle(id) {
|
||||||
|
const item = state.preparedTitles.find((entry) => String(entry?.id) === String(id));
|
||||||
|
if (!item) return false;
|
||||||
|
const input = hockeyPreparedInventoryMatch(item.clone_input) || hockeyPreparedInventoryMatch(item.source_input);
|
||||||
|
state.preparedTitleEditingId = String(item.id || "");
|
||||||
|
state.preparedTitleName = String(item.name || "");
|
||||||
|
state.preparedTitlePanelId = "";
|
||||||
|
state.preparedTitleMappingSources = [];
|
||||||
|
state.preparedTitleSourceKey = input ? hockeyPreparedSourceIdentity(input) : "";
|
||||||
|
state.preparedTitleFieldValues = {};
|
||||||
|
if (input) hockeyPreparedFieldValuesForInput(input, { preserve: false });
|
||||||
|
Object.entries(item.field_values || {}).forEach(([name, raw]) => {
|
||||||
|
if (!name) return;
|
||||||
|
const entry = raw && typeof raw === "object" ? raw : { value: raw, type: "text" };
|
||||||
|
const currentType = state.preparedTitleFieldValues?.[name]?.type || String(entry.type || "text");
|
||||||
|
state.preparedTitleFieldValues[name] = {
|
||||||
|
type: currentType,
|
||||||
|
value: String(entry.value ?? ""),
|
||||||
|
touched: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
renderRuntime();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyCancelPreparedEdit() {
|
||||||
|
state.preparedTitleEditingId = "";
|
||||||
|
state.preparedTitleName = "";
|
||||||
|
state.preparedTitleSourceKey = "";
|
||||||
|
state.preparedTitleFieldValues = {};
|
||||||
|
renderRuntime();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hockeyUpdatePreparedTitle() {
|
||||||
|
const id = String(state.preparedTitleEditingId || "").trim();
|
||||||
|
if (!id) return false;
|
||||||
|
const fieldValues = {};
|
||||||
|
Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => {
|
||||||
|
if (!entry?.touched) return;
|
||||||
|
fieldValues[fieldName] = {
|
||||||
|
type: String(entry?.type || "text"),
|
||||||
|
value: String(entry?.value ?? ""),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
state.preparedTitlesLoading = true;
|
||||||
|
renderRuntime();
|
||||||
|
try {
|
||||||
|
const payload = await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: String(state.preparedTitleName || "").trim(),
|
||||||
|
device_id: currentRuntimeVmixDeviceId(),
|
||||||
|
session_token: currentRuntimeHockeySessionToken(),
|
||||||
|
field_values: fieldValues,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
toast(`Заготовка обновлена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`);
|
||||||
|
state.preparedTitleEditingId = "";
|
||||||
|
state.preparedTitleName = "";
|
||||||
|
state.preparedTitleSourceKey = "";
|
||||||
|
state.preparedTitleFieldValues = {};
|
||||||
|
state.preparedTitlesLoadedKey = "";
|
||||||
|
await hockeyLoadPreparedTitlesWorkspace({ force: true, render: false });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Не удалось обновить заготовку: ${error.message}`, true);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
state.preparedTitlesLoading = false;
|
||||||
|
renderRuntime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function hockeyPreviewPreparedTitle(id) {
|
async function hockeyPreviewPreparedTitle(id) {
|
||||||
try {
|
try {
|
||||||
await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}/preview`, {
|
await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}/preview`, {
|
||||||
@@ -9457,6 +9582,7 @@ async function hockeyDeletePreparedTitle(id) {
|
|||||||
try {
|
try {
|
||||||
await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" });
|
await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id));
|
state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id));
|
||||||
|
if (String(state.preparedTitleEditingId || "") === String(id)) hockeyCancelPreparedEdit();
|
||||||
renderRuntime();
|
renderRuntime();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -9467,8 +9593,9 @@ async function hockeyDeletePreparedTitle(id) {
|
|||||||
|
|
||||||
function hockeyOpenPreparedFromPlayerPanel(panel) {
|
function hockeyOpenPreparedFromPlayerPanel(panel) {
|
||||||
if (!panel) return false;
|
if (!panel) return false;
|
||||||
|
state.preparedTitleEditingId = "";
|
||||||
state.preparedTitlePanelId = String(panel.id || "");
|
state.preparedTitlePanelId = String(panel.id || "");
|
||||||
state.preparedTitleName = `${String(panel.label || "Выбор игроков")} · заготовка`;
|
state.preparedTitleName = "";
|
||||||
state.preparedTitleSourceKey = "";
|
state.preparedTitleSourceKey = "";
|
||||||
state.preparedTitleFieldValues = {};
|
state.preparedTitleFieldValues = {};
|
||||||
state.preparedTitleMappingSources = [];
|
state.preparedTitleMappingSources = [];
|
||||||
@@ -9491,6 +9618,8 @@ function renderHockeyPreparedTitlesWorkspace() {
|
|||||||
: [];
|
: [];
|
||||||
const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null;
|
const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null;
|
||||||
const mappingSources = Array.isArray(state.preparedTitleMappingSources) ? state.preparedTitleMappingSources : [];
|
const mappingSources = Array.isArray(state.preparedTitleMappingSources) ? state.preparedTitleMappingSources : [];
|
||||||
|
const editingId = String(state.preparedTitleEditingId || "").trim();
|
||||||
|
const editingItem = editingId ? state.preparedTitles.find((item) => String(item?.id) === editingId) : null;
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="hockey-prepared-head">
|
<div class="hockey-prepared-head">
|
||||||
@@ -9510,8 +9639,8 @@ function renderHockeyPreparedTitlesWorkspace() {
|
|||||||
</aside>
|
</aside>
|
||||||
<main class="hockey-prepared-editor">
|
<main class="hockey-prepared-editor">
|
||||||
${selected ? `
|
${selected ? `
|
||||||
<div class="hockey-prepared-source"><div><span>ИСТОЧНИК</span><strong>#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}</strong><small>${escapeHtml(selected.type || "")} · элементов ${fields.length}</small></div>${panel ? `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>${state.preparedTitleSnapshotLoading ? "Подставляю…" : "Подставить из Mapping"}</button>` : `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>Из Mapping</button>`}</div>
|
<div class="hockey-prepared-source"><div><span>${editingItem ? "РЕДАКТИРОВАНИЕ" : "ИСТОЧНИК"}</span><strong>#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}</strong><small>${editingItem ? `Заготовка #${escapeHtml(editingItem.id)} · изменяется существующий Input` : `${escapeHtml(selected.type || "")} · элементов ${fields.length}`}</small></div>${editingItem ? `<button type="button" data-prepared-cancel-edit>Отмена</button>` : panel ? `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>${state.preparedTitleSnapshotLoading ? "Подставляю…" : "Подставить из Mapping"}</button>` : `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>Из Mapping</button>`}</div>
|
||||||
<label class="hockey-prepared-name"><span>Название заготовки</span><input type="text" data-prepared-name value="${escapeHtml(state.preparedTitleName)}" placeholder="Например: Сравнение вратарей · студия"></label>
|
<label class="hockey-prepared-name"><span>Название заготовки</span><input type="text" data-prepared-name value="${escapeHtml(state.preparedTitleName)}" placeholder="Если пусто — Заготовка N"></label>
|
||||||
<div class="hockey-prepared-fields">${fields.map((field) => {
|
<div class="hockey-prepared-fields">${fields.map((field) => {
|
||||||
const name = String(field?.name || "");
|
const name = String(field?.name || "");
|
||||||
const type = hockeyPreparedFieldType(field);
|
const type = hockeyPreparedFieldType(field);
|
||||||
@@ -9519,12 +9648,12 @@ function renderHockeyPreparedTitlesWorkspace() {
|
|||||||
const color = /^#[0-9A-Fa-f]{6}$/.test(value) ? value : "#ffffff";
|
const color = /^#[0-9A-Fa-f]{6}$/.test(value) ? value : "#ffffff";
|
||||||
return `<label class="hockey-prepared-field" data-field-type="${escapeHtml(type)}"><span><b>${escapeHtml(name)}</b><small>${escapeHtml(type)}</small></span><div>${type === "color" ? `<input type="color" data-prepared-color="${escapeHtml(name)}" value="${escapeHtml(color)}">` : ""}<input type="text" data-prepared-field="${escapeHtml(name)}" data-prepared-field-type="${escapeHtml(type)}" value="${escapeHtml(value)}" placeholder="${type === "image" ? "путь к изображению" : type === "color" ? "#RRGGBB" : "текст"}"></div></label>`;
|
return `<label class="hockey-prepared-field" data-field-type="${escapeHtml(type)}"><span><b>${escapeHtml(name)}</b><small>${escapeHtml(type)}</small></span><div>${type === "color" ? `<input type="color" data-prepared-color="${escapeHtml(name)}" value="${escapeHtml(color)}">` : ""}<input type="text" data-prepared-field="${escapeHtml(name)}" data-prepared-field-type="${escapeHtml(type)}" value="${escapeHtml(value)}" placeholder="${type === "image" ? "путь к изображению" : type === "color" ? "#RRGGBB" : "текст"}"></div></label>`;
|
||||||
}).join("") || `<div class="hockey-prepared-empty">У Input нет доступных элементов</div>`}</div>
|
}).join("") || `<div class="hockey-prepared-empty">У Input нет доступных элементов</div>`}</div>
|
||||||
<div class="hockey-prepared-savebar"><small>При сохранении vMix создаст виртуальную копию этого Input в конце проекта и заполнит её текущими значениями.</small><button type="button" data-prepared-save ${state.preparedTitlesLoading ? "disabled" : ""}>${state.preparedTitlesLoading ? "Сохраняю…" : "+ Сохранить новым Input"}</button></div>
|
<div class="hockey-prepared-savebar"><small>${editingItem ? "Изменения применятся к уже созданному vMix Input: название и отредактированные поля обновятся без создания новой копии." : "Создаётся виртуальная копия, Input получает имя заготовки. vMix API не даёт назначить/создать категорию программно, поэтому копия остаётся в конце проекта."}</small><button type="button" ${editingItem ? "data-prepared-update" : "data-prepared-save"} ${state.preparedTitlesLoading ? "disabled" : ""}>${state.preparedTitlesLoading ? "Сохраняю…" : editingItem ? "✓ Сохранить изменения" : "+ Сохранить новым Input"}</button></div>
|
||||||
` : `<div class="hockey-prepared-editor-empty"><b>Выберите vMix Input</b><span>Справа появятся все его .Text / .Source / .Color элементы для ручной заготовки.</span></div>`}
|
` : `<div class="hockey-prepared-editor-empty"><b>Выберите vMix Input</b><span>Справа появятся все его .Text / .Source / .Color элементы для ручной заготовки.</span></div>`}
|
||||||
</main>
|
</main>
|
||||||
<aside class="hockey-prepared-saved">
|
<aside class="hockey-prepared-saved">
|
||||||
<div class="hockey-prepared-saved-head"><span>СОХРАНЁННЫЕ</span><b>${state.preparedTitles.length}</b></div>
|
<div class="hockey-prepared-saved-head"><span>СОХРАНЁННЫЕ</span><b>${state.preparedTitles.length}</b></div>
|
||||||
<div class="hockey-prepared-saved-list">${state.preparedTitles.map((item) => `<article><div><span>${escapeHtml(item.source_kind === "player_selection" ? "Блок игроков" : "Ручная")}</span><strong>${escapeHtml(item.name || "Заготовка")}</strong><small>Input #${escapeHtml(item.clone_input?.number || "—")} · из #${escapeHtml(item.source_input?.number || "—")} ${escapeHtml(item.source_input?.title || "")}</small></div><div><button type="button" data-prepared-preview="${escapeHtml(item.id)}">▶ Preview</button><button type="button" class="danger" data-prepared-delete="${escapeHtml(item.id)}" title="Убрать из списка, не удаляя Input в vMix">×</button></div></article>`).join("") || `<div class="hockey-prepared-empty">Для этого матча заготовок пока нет</div>`}</div>
|
<div class="hockey-prepared-saved-list">${state.preparedTitles.map((item) => `<article class="${String(item.id) === editingId ? "editing" : ""}"><div><span>${escapeHtml(item.source_kind === "player_selection" ? "Блок игроков" : "Ручная")}</span><strong>${escapeHtml(item.name || `Заготовка ${item.id || ""}`)}</strong><small>Input #${escapeHtml(item.clone_input?.number || "—")} · ${escapeHtml(item.clone_input?.title || item.name || "")} · из #${escapeHtml(item.source_input?.number || "—")}</small></div><div><button type="button" data-prepared-edit="${escapeHtml(item.id)}" title="Изменить название и данные">✎</button><button type="button" data-prepared-preview="${escapeHtml(item.id)}">▶ Preview</button><button type="button" class="danger" data-prepared-delete="${escapeHtml(item.id)}" title="Убрать из списка, не удаляя Input в vMix">×</button></div></article>`).join("") || `<div class="hockey-prepared-empty">Для этого матча заготовок пока нет</div>`}</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -9562,6 +9691,7 @@ function renderHockeyPreparedTitlesWorkspace() {
|
|||||||
renderRuntime();
|
renderRuntime();
|
||||||
});
|
});
|
||||||
root.querySelector("[data-prepared-apply-mapping]")?.addEventListener("click", () => hockeyApplyPreparedMappingSnapshot());
|
root.querySelector("[data-prepared-apply-mapping]")?.addEventListener("click", () => hockeyApplyPreparedMappingSnapshot());
|
||||||
|
root.querySelector("[data-prepared-cancel-edit]")?.addEventListener("click", () => hockeyCancelPreparedEdit());
|
||||||
root.querySelector("[data-prepared-name]")?.addEventListener("input", (event) => { state.preparedTitleName = event.target.value || ""; });
|
root.querySelector("[data-prepared-name]")?.addEventListener("input", (event) => { state.preparedTitleName = event.target.value || ""; });
|
||||||
root.querySelectorAll("[data-prepared-field]").forEach((inputNode) => inputNode.addEventListener("input", () => {
|
root.querySelectorAll("[data-prepared-field]").forEach((inputNode) => inputNode.addEventListener("input", () => {
|
||||||
const name = inputNode.dataset.preparedField || "";
|
const name = inputNode.dataset.preparedField || "";
|
||||||
@@ -9579,6 +9709,8 @@ function renderHockeyPreparedTitlesWorkspace() {
|
|||||||
state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true };
|
state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true };
|
||||||
}));
|
}));
|
||||||
root.querySelector("[data-prepared-save]")?.addEventListener("click", () => hockeyCreatePreparedTitle());
|
root.querySelector("[data-prepared-save]")?.addEventListener("click", () => hockeyCreatePreparedTitle());
|
||||||
|
root.querySelector("[data-prepared-update]")?.addEventListener("click", () => hockeyUpdatePreparedTitle());
|
||||||
|
root.querySelectorAll("[data-prepared-edit]").forEach((button) => button.addEventListener("click", () => hockeyBeginEditPreparedTitle(button.dataset.preparedEdit)));
|
||||||
root.querySelectorAll("[data-prepared-preview]").forEach((button) => button.addEventListener("click", () => hockeyPreviewPreparedTitle(button.dataset.preparedPreview)));
|
root.querySelectorAll("[data-prepared-preview]").forEach((button) => button.addEventListener("click", () => hockeyPreviewPreparedTitle(button.dataset.preparedPreview)));
|
||||||
root.querySelectorAll("[data-prepared-delete]").forEach((button) => button.addEventListener("click", () => hockeyDeletePreparedTitle(button.dataset.preparedDelete)));
|
root.querySelectorAll("[data-prepared-delete]").forEach((button) => button.addEventListener("click", () => hockeyDeletePreparedTitle(button.dataset.preparedDelete)));
|
||||||
return root;
|
return root;
|
||||||
@@ -12935,7 +13067,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
<div class="editor-pin-icon">⌘</div>
|
<div class="editor-pin-icon">⌘</div>
|
||||||
<div class="editor-pin-copy">
|
<div class="editor-pin-copy">
|
||||||
<strong>Защищённый режим конструктора</strong>
|
<strong>Защищённый режим конструктора</strong>
|
||||||
<p>Введите ежедневный четырёхзначный PIN или аварийный мастер-PIN.</p>
|
<p>Введите PIN доступа к конструктору.</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="editor-pin-field">
|
<label class="editor-pin-field">
|
||||||
<span>PIN-код</span>
|
<span>PIN-код</span>
|
||||||
|
|||||||
@@ -7666,6 +7666,7 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
|
|||||||
.hockey-prepared-saved-head { min-height:28px; padding:0 2px; }
|
.hockey-prepared-saved-head { min-height:28px; padding:0 2px; }
|
||||||
.hockey-prepared-saved-head b { min-width:22px; height:18px; display:grid; place-items:center; border-radius:9px; color:#9ec1d8; background:#1b3850; font:900 8px "Roboto Mono",Consolas,monospace; }
|
.hockey-prepared-saved-head b { min-width:22px; height:18px; display:grid; place-items:center; border-radius:9px; color:#9ec1d8; background:#1b3850; font:900 8px "Roboto Mono",Consolas,monospace; }
|
||||||
.hockey-prepared-saved article { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px; align-items:center; padding:7px; border:1px solid #243e54; border-radius:8px; background:#0d2031; }
|
.hockey-prepared-saved article { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px; align-items:center; padding:7px; border:1px solid #243e54; border-radius:8px; background:#0d2031; }
|
||||||
|
.hockey-prepared-saved article.editing { border-color:#4dddbc; box-shadow:inset 3px 0 0 #4dddbc; background:#102b34; }
|
||||||
.hockey-prepared-saved article > div:first-child { min-width:0; display:grid; gap:2px; }
|
.hockey-prepared-saved article > div:first-child { min-width:0; display:grid; gap:2px; }
|
||||||
.hockey-prepared-saved article span { color:#5c8ca6; font-size:6px; font-weight:950; text-transform:uppercase; }
|
.hockey-prepared-saved article span { color:#5c8ca6; font-size:6px; font-weight:950; text-transform:uppercase; }
|
||||||
.hockey-prepared-saved article strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; color:#e1eef8; }
|
.hockey-prepared-saved article strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; color:#e1eef8; }
|
||||||
|
|||||||
Reference in New Issue
Block a user