выделение удалений, и заготовки

This commit is contained in:
2026-08-20 13:33:42 +03:00
parent 3cea13c103
commit 381e630c77
7 changed files with 1511 additions and 49 deletions

View File

@@ -17,7 +17,7 @@ from sqlalchemy import and_, delete, desc, select
from .auth_bridge import HockeyUser
from .database import HockeyDatabase
from .mapping_context import MappingDataService
from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField)
from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField, VmixPreparedTitle)
AGENT_PROTOCOL_VERSION = 1
@@ -2010,6 +2010,394 @@ class VmixAgentHub:
})
return {"devices": items}
@staticmethod
def _prepared_inventory_from_row(device: VmixDevice | None) -> dict[str, Any]:
if device is None:
return {}
try:
parsed = json.loads(device.project_inventory_json or "{}")
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
@staticmethod
def _prepared_find_input(inventory: dict[str, Any], *, key: str = "", number: str = "", title: str = "") -> dict[str, Any] | None:
inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
key = str(key or "").strip()
number = str(number or "").strip()
title = str(title or "").strip()
if key:
found = next((item for item in inputs if isinstance(item, dict) and str(item.get("key") or "").strip() == key), None)
if found is not None:
return found
if number:
found = next((item for item in inputs if isinstance(item, dict) and str(item.get("number") or "").strip() == number), None)
if found is not None:
return found
if title:
matches = [item for item in inputs if isinstance(item, dict) and str(item.get("title") or "").strip() == title]
if len(matches) == 1:
return matches[0]
return None
def _prepared_device_for_user(self, session: Any, user: HockeyUser, requested_device_id: str = "") -> VmixDevice:
requested = str(requested_device_id or "").strip()
if requested:
requested = self.normalise_device_id(requested)
device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id)))
if device is None:
raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
return device
rows = list(session.scalars(
select(VmixDevice)
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
.order_by(desc(VmixDevice.last_seen_at))
))
if not rows:
raise HTTPException(status_code=409, detail="Hockey Agent не выбран")
live = next((row for row in rows if row.device_uuid in self._live and bool(row.vmix_connected)), None)
return live or rows[0]
@staticmethod
def _prepared_title_payload(row: VmixPreparedTitle, inventory: dict[str, Any] | None = None) -> dict[str, Any]:
try:
fields = json.loads(row.field_values_json or "{}")
if not isinstance(fields, dict):
fields = {}
except Exception:
fields = {}
clone_key = str(row.clone_input_key or "")
clone_number = str(row.clone_input_number or "")
clone_title = str(row.clone_input_title or "")
if inventory:
match = VmixAgentHub._prepared_find_input(inventory, key=clone_key, number=clone_number, title=clone_title)
if match is not None:
clone_key = str(match.get("key") or clone_key)
clone_number = str(match.get("number") or clone_number)
clone_title = str(match.get("title") or clone_title)
return {
"id": row.id,
"name": row.name,
"game_id": row.game_external_id,
"device_id": row.device_uuid,
"source_kind": row.source_kind,
"source_ref": row.source_ref,
"source_input": {
"key": row.source_input_key,
"number": row.source_input_number,
"title": row.source_input_title,
},
"clone_input": {"key": clone_key, "number": clone_number, "title": clone_title},
"field_values": fields,
"created_at": row.created_at.isoformat() if row.created_at else "",
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
}
async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id)
resolved_game = str(game_id or device.current_match_external_id or "").strip()
query = select(VmixPreparedTitle).where(VmixPreparedTitle.wfl_user_id == user.id)
if resolved_game:
query = query.where(VmixPreparedTitle.game_external_id == resolved_game)
if device.device_uuid:
query = query.where(VmixPreparedTitle.device_uuid == device.device_uuid)
rows = list(session.scalars(query.order_by(desc(VmixPreparedTitle.created_at), desc(VmixPreparedTitle.id))))
inventory = self._prepared_inventory_from_row(device)
# Refresh clone key/title opportunistically after Agent inventory catches up.
for row in rows:
found = 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 ""),
)
if found is not None:
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_title = str(found.get("title") or row.clone_input_title or "")[:300]
items = [self._prepared_title_payload(row, inventory) for row in rows]
return {
"device_id": device.device_uuid,
"game_id": resolved_game,
"items": items,
}
def _prepared_mapping_context(self, session: Any, device: VmixDevice, user: HockeyUser) -> tuple[VmixMappingProfile | None, dict[str, Any], dict[str, Any]]:
profile = self._active_mapping_profile_for_device(session, device)
if profile is None:
return None, {}, {}
assignment = session.scalar(
select(VmixAssignment)
.where(and_(VmixAssignment.device_id == device.id, VmixAssignment.active.is_(True)))
.order_by(desc(VmixAssignment.id))
)
context = {
"game_id": str(device.current_match_external_id or ""),
"device_id": device.device_uuid,
"assignment_id": str(device.current_assignment_key or ""),
"tournament_id": str(assignment.tournament_external_id or "") if assignment is not None else "",
}
preference = session.get(UserPreference, user.id)
if preference is not None:
language = str(preference.vmix_language or preference.display_language or "").strip().lower()
if language in {"ru", "en"}:
context["ui_language"] = language
inventory = self._prepared_inventory_from_row(device)
return profile, context, inventory
async def prepared_mapping_sources(self, user: HockeyUser, *, device_id: str = "", panel_id: str = "") -> dict[str, Any]:
panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id)
profile, _context, inventory = self._prepared_mapping_context(session, device, user)
if profile is None:
return {"device_id": device.device_uuid, "profile_id": 0, "items": []}
fields = list(session.scalars(
select(VmixMappingField)
.where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
if panel_id:
prefix = f"player_select.{panel_id}."
fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
grouped: dict[str, dict[str, Any]] = {}
for field in fields:
identity = str(field.vmix_input_key or field.vmix_input_title or field.vmix_input_number or "").strip()
if not identity:
continue
item = grouped.setdefault(identity, {
"key": str(field.vmix_input_key or ""),
"number": str(field.vmix_input_number or ""),
"title": str(field.vmix_input_title or ""),
"mapped_fields": 0,
})
item["mapped_fields"] += 1
for item in grouped.values():
found = self._prepared_find_input(inventory, key=item["key"], number=item["number"], title=item["title"])
if found is not None:
item.update({
"key": str(found.get("key") or item["key"]),
"number": str(found.get("number") or item["number"]),
"title": str(found.get("title") or item["title"]),
"type": str(found.get("type") or ""),
})
items = sorted(grouped.values(), key=lambda value: (int(value.get("number") or 999999) if str(value.get("number") or "").isdigit() else 999999, str(value.get("title") or "").casefold()))
return {"device_id": device.device_uuid, "profile_id": profile.id, "items": items}
async def prepared_mapping_snapshot(
self,
user: HockeyUser,
*,
device_id: str = "",
input_key: str = "",
input_number: str = "",
input_title: str = "",
panel_id: str = "",
) -> dict[str, Any]:
panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id)
profile, context, inventory = self._prepared_mapping_context(session, device, user)
if profile is None:
return {"device_id": device.device_uuid, "profile_id": 0, "fields": []}
source = self._prepared_find_input(inventory, key=input_key, number=input_number, title=input_title)
source_key = str(source.get("key") or input_key or "") if source else str(input_key or "")
source_number = str(source.get("number") or input_number or "") if source else str(input_number or "")
source_title = str(source.get("title") or input_title or "") if source else str(input_title or "")
fields = list(session.scalars(
select(VmixMappingField)
.where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
if panel_id:
prefix = f"player_select.{panel_id}."
fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
def target_matches(field: VmixMappingField) -> bool:
if source_key and str(field.vmix_input_key or "") == source_key:
return True
if source_title and str(field.vmix_input_title or "") == source_title:
return True
if source_number and str(field.vmix_input_number or "") == source_number:
return True
return False
fields = [field for field in fields if target_matches(field)]
catalog = self.mapping_data.data_catalog(user, context)
by_key = {str(item.get("key") or ""): item for item in (catalog.get("items") or []) if item.get("key")}
result_fields = []
for field in fields:
item = by_key.get(str(field.data_key or ""))
if item is None:
continue
result_fields.append({
"name": str(field.vmix_field or ""),
"type": str(field.field_type or "text"),
"data_key": str(field.data_key or ""),
"value": "" if item.get("value") is None else str(item.get("value")),
})
return {
"device_id": device.device_uuid,
"game_id": str(device.current_match_external_id or ""),
"profile_id": profile.id,
"input": {"key": source_key, "number": source_number, "title": source_title},
"fields": result_fields,
}
async def create_prepared_title(self, user: HockeyUser, payload: Any) -> dict[str, Any]:
requested_device = str(getattr(payload, "device_id", "") or "").strip()
session_token = str(getattr(payload, "session_token", "") or "").strip()
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, requested_device)
inventory = self._prepared_inventory_from_row(device)
source = self._prepared_find_input(
inventory,
key=str(getattr(payload, "source_input_key", "") or ""),
number=str(getattr(payload, "source_input_number", "") or ""),
title=str(getattr(payload, "source_input_title", "") or ""),
)
if source is None:
raise HTTPException(status_code=404, detail="Исходный vMix Input не найден в текущем проекте")
source_key = str(source.get("key") or "")
source_number = str(source.get("number") or "")
source_title = str(source.get("title") or "")
source_ref = source_key or source_number or source_title
before_inputs = [item for item in (inventory.get("inputs") or []) if isinstance(item, dict)]
before_keys = {str(item.get("key") or "") for item in before_inputs if str(item.get("key") or "")}
before_numbers = {str(item.get("number") or "") for item in before_inputs if str(item.get("number") or "")}
numeric_numbers = [int(value) for value in before_numbers if value.isdigit()]
expected_number = str((max(numeric_numbers) if numeric_numbers else len(before_inputs)) + 1)
create_result = await self.run_vmix_sequence_for_user(
user,
[{"Function": "CreateVirtualInput", "Input": source_ref}],
device_id=device.device_uuid,
session_token=session_token,
timeout=6.0,
)
target_device_id = str(create_result.get("device_id") or device.device_uuid)
match_id = str(create_result.get("match_id") or "")
clone: dict[str, Any] | None = None
for _ in range(12):
await asyncio.sleep(0.18)
with self.database.session() as session:
refreshed = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == target_device_id))
current_inventory = self._prepared_inventory_from_row(refreshed)
current_inputs = [item for item in (current_inventory.get("inputs") or []) if isinstance(item, dict)]
candidates = [item for item in current_inputs if (str(item.get("key") or "") and str(item.get("key") or "") not in before_keys) or (str(item.get("number") or "") and str(item.get("number") or "") not in before_numbers)]
if candidates:
clone = sorted(candidates, key=lambda item: int(item.get("number") or 0) if str(item.get("number") or "").isdigit() else 0)[-1]
break
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_title = 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 = raw_values if isinstance(raw_values, dict) 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 "")}
commands: list[dict[str, Any]] = []
saved_fields: dict[str, dict[str, str]] = {}
for name, field in list(allowed.items())[:240]:
if name not in raw_values:
continue
raw_entry = raw_values.get(name)
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 "")
else:
value = "" if raw_entry is None else str(raw_entry)
requested_type = ""
field_type = str(field.get("type") or requested_type or "text").lower()
if field_type not in {"text", "image", "source", "color", "colour"}:
field_type = requested_type.lower() if requested_type.lower() in {"text", "image", "source", "color", "colour"} else "text"
saved_fields[name] = {"value": value[:8000], "type": field_type}
# Empty text is meaningful (clear inherited content). Empty image/color is
# treated as "leave inherited value" because vMix rejects some empty sources.
if not value and field_type in {"image", "source", "color", "colour"}:
continue
commands.append({
"Function": _mapping_value_function(field_type),
"Input": clone_ref,
"SelectedName": name,
"Value": value,
})
set_result = None
if 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)),
)
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_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128]
with self.database.session() as session:
row = VmixPreparedTitle(
wfl_user_id=user.id,
game_external_id=match_id,
device_uuid=target_device_id,
name=title_name,
source_kind=source_kind,
source_ref=source_ref_name,
source_input_key=source_key,
source_input_number=source_number,
source_input_title=source_title,
clone_input_key=clone_key,
clone_input_number=clone_number,
clone_input_title=clone_title,
field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
created_by=user.id,
created_at=_utcnow(),
updated_at=_utcnow(),
)
session.add(row)
session.flush()
result = self._prepared_title_payload(row)
result["ok"] = True
result["create_result"] = create_result
result["set_result"] = set_result or {"ok": True, "applied": 0}
return result
async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
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)
found = 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 ""),
)
input_ref = str(found.get("key") or found.get("number") or "") if found is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
if not input_ref:
raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
return await self.run_vmix_sequence_for_user(
user,
[{"Function": "PreviewInput", "Input": input_ref}],
device_id=device.device_uuid,
session_token=str(getattr(payload, "session_token", "") or ""),
timeout=4.0,
)
async def delete_prepared_title(self, prepared_id: int, user: HockeyUser) -> dict[str, Any]:
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="Заготовка не найдена")
# Deliberately do not RemoveInput in vMix: deleting a web list entry must
# never unexpectedly destroy a studio graphic that may already be used.
session.delete(row)
return {"ok": True, "id": int(prepared_id), "vmix_input_removed": False}
async def latest_vmix_inventory(self) -> dict[str, Any]:
"""Return the most recent connected vMix project inventory for editor helpers.
@@ -3183,6 +3571,23 @@ class MappingSqlPreviewPayload(BaseModel):
context: dict[str, Any] = Field(default_factory=dict)
class PreparedTitleCreatePayload(BaseModel):
name: str = Field(default="", max_length=200)
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
source_input_key: str = Field(default="", max_length=128)
source_input_number: str = Field(default="", max_length=32)
source_input_title: str = Field(default="", max_length=300)
source_kind: str = Field(default="manual", max_length=32)
source_ref: str = Field(default="", max_length=128)
field_values: dict[str, Any] = Field(default_factory=dict)
class PreparedTitlePreviewPayload(BaseModel):
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
class AgentHelloPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
type: str = "hello"
@@ -3215,6 +3620,57 @@ def create_hockey_agent_router(
) -> dict[str, Any]:
return await hub.vmix_inventory_for_user(user, device_id=device_id)
@router.get("/api/hockey/prepared-titles")
async def prepared_titles_list(
device_id: str = Query("", max_length=128),
game_id: str = Query("", max_length=64),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.list_prepared_titles(user, device_id=device_id, game_id=game_id)
@router.get("/api/hockey/prepared-titles/mapping-sources")
async def prepared_titles_mapping_sources(
device_id: str = Query("", max_length=128),
panel_id: str = Query("", max_length=64),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.prepared_mapping_sources(user, device_id=device_id, panel_id=panel_id)
@router.get("/api/hockey/prepared-titles/mapping-snapshot")
async def prepared_titles_mapping_snapshot(
device_id: str = Query("", max_length=128),
input_key: str = Query("", max_length=128),
input_number: str = Query("", max_length=32),
input_title: str = Query("", max_length=300),
panel_id: str = Query("", max_length=64),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.prepared_mapping_snapshot(
user, device_id=device_id, input_key=input_key, input_number=input_number, input_title=input_title, panel_id=panel_id
)
@router.post("/api/hockey/prepared-titles")
async def prepared_title_create(
payload: PreparedTitleCreatePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.create_prepared_title(user, payload)
@router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
async def prepared_title_preview(
prepared_id: int,
payload: PreparedTitlePreviewPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.preview_prepared_title(prepared_id, user, payload)
@router.delete("/api/hockey/prepared-titles/{prepared_id}")
async def prepared_title_delete(
prepared_id: int,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.delete_prepared_title(prepared_id, user)
@router.post("/api/hockey/agents/devices/{device_id}/pair")
async def pair_device(
device_id: str,

View File

@@ -76,6 +76,15 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
{"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
{"key": "active_home_penalty_id", "label": "Активное удаление левой команды — ID", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"},
{"key": "active_home_penalty_player_id", "label": "Активное удаление левой команды — игрок external ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "active_home_penalty_player_db_id", "label": "Активное удаление левой команды — игрок DB ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "active_home_penalty_team_penalty", "label": "Активное удаление левой команды — командное (1/0)", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
{"key": "active_away_penalty_id", "label": "Активное удаление правой команды — ID", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"},
{"key": "active_away_penalty_player_id", "label": "Активное удаление правой команды — игрок external ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "active_away_penalty_player_db_id", "label": "Активное удаление правой команды — игрок DB ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "active_away_penalty_team_penalty", "label": "Активное удаление правой команды — командное (1/0)", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
{"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
@@ -95,9 +104,9 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
)
# These values describe the live state of the match itself, not a private UI
# selection of one account. They must be visible to Mapping regardless of which
# operator/admin account opened the Mapping editor.
# Side-specific penalty selections and active-penalty mirrors are shared inside
# one match. This lets the operator select a HOME/AWAY penalty while an admin
# watches Mapping from another account without getting a private copy.
MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({
"selected_home_penalty_id",
"selected_home_penalty_player_id",
@@ -107,6 +116,14 @@ MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({
"selected_away_penalty_player_id",
"selected_away_penalty_player_db_id",
"selected_away_penalty_team_penalty",
"active_home_penalty_id",
"active_home_penalty_player_id",
"active_home_penalty_player_db_id",
"active_home_penalty_team_penalty",
"active_away_penalty_id",
"active_away_penalty_player_id",
"active_away_penalty_player_db_id",
"active_away_penalty_team_penalty",
})

View File

@@ -981,6 +981,39 @@ class VmixAssignment(Base):
)
class VmixPreparedTitle(Base):
"""A match-scoped vMix title clone prepared by an operator.
The actual graphic lives in vMix. This row only remembers which source was
cloned, where the clone ended up and the field snapshot that was applied.
"""
__tablename__ = "hockey_vmix_prepared_titles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
wfl_user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
game_external_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
device_uuid: Mapped[str] = mapped_column(String(128), nullable=False, default="", index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, default="")
source_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="manual")
source_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="")
source_input_key: Mapped[str] = mapped_column(String(128), nullable=False, default="")
source_input_number: Mapped[str] = mapped_column(String(32), nullable=False, default="")
source_input_title: Mapped[str] = mapped_column(String(300), nullable=False, default="")
clone_input_key: Mapped[str] = mapped_column(String(128), nullable=False, default="")
clone_input_number: Mapped[str] = mapped_column(String(32), nullable=False, default="")
clone_input_title: Mapped[str] = mapped_column(String(300), nullable=False, default="")
field_values_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_by: Mapped[str] = mapped_column(String(128), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
Index("ix_hockey_prepared_titles_user_game", "wfl_user_id", "game_external_id", "created_at"),
Index("ix_hockey_prepared_titles_device", "device_uuid", "created_at"),
)
class VmixMappingProfile(Base):
__tablename__ = "hockey_vmix_mapping_profiles"