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

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,