маппинг
This commit is contained in:
@@ -12,7 +12,7 @@ from typing import Any, Callable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import and_, desc, select
|
||||
from sqlalchemy import and_, delete, desc, select
|
||||
|
||||
from .auth_bridge import HockeyUser
|
||||
from .database import HockeyDatabase
|
||||
@@ -1879,6 +1879,20 @@ class VmixAgentHub:
|
||||
]
|
||||
return payload
|
||||
|
||||
def _mapping_device_display_payload(self, session: Any, profile: VmixMappingProfile) -> dict[str, Any]:
|
||||
source_id = self._runtime_mapping_source_id(profile)
|
||||
if source_id:
|
||||
source = session.get(VmixMappingProfile, source_id)
|
||||
if source is not None:
|
||||
return {
|
||||
"id": source.id,
|
||||
"name": source.name,
|
||||
"version": source.version,
|
||||
"runtime_profile_id": profile.id,
|
||||
"source_profile_id": source.id,
|
||||
}
|
||||
return {"id": profile.id, "name": profile.name, "version": profile.version, "source_profile_id": profile.id}
|
||||
|
||||
async def list_mapping_devices(self) -> dict[str, Any]:
|
||||
with self.database.session() as session:
|
||||
rows = list(session.scalars(select(VmixDevice).order_by(desc(VmixDevice.last_seen_at))))
|
||||
@@ -1897,7 +1911,7 @@ class VmixAgentHub:
|
||||
"input_count": row.project_input_count,
|
||||
"field_count": row.project_field_count,
|
||||
"scanned_at": row.project_scanned_at.isoformat() if row.project_scanned_at else "",
|
||||
"mapping": ({"id": profile.id, "name": profile.name, "version": profile.version} if profile else None),
|
||||
"mapping": (self._mapping_device_display_payload(session, profile) if profile else None),
|
||||
})
|
||||
return {"devices": items}
|
||||
|
||||
@@ -2182,6 +2196,198 @@ class VmixAgentHub:
|
||||
session.flush()
|
||||
return profile, report
|
||||
|
||||
@staticmethod
|
||||
def _runtime_mapping_profile_name(source_profile_id: int, target_fingerprint: str) -> str:
|
||||
"""Deterministic hidden profile used only to adapt one saved config to another vMix."""
|
||||
safe_fp = re.sub(r"[^a-zA-Z0-9]+", "", str(target_fingerprint or ""))[:32] or "project"
|
||||
return f"__AUTO_MAPPING__{int(source_profile_id)}__{safe_fp}"[:200]
|
||||
|
||||
@staticmethod
|
||||
def _runtime_mapping_source_id(profile: VmixMappingProfile | None) -> int:
|
||||
if profile is None:
|
||||
return 0
|
||||
match = re.match(r"^__AUTO_MAPPING__(\d+)__", str(profile.name or ""))
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
def _upsert_runtime_mapping_for_device(
|
||||
self,
|
||||
session: Any,
|
||||
*,
|
||||
source: VmixMappingProfile,
|
||||
source_fields: list[dict[str, Any]],
|
||||
device: VmixDevice,
|
||||
user: HockeyUser,
|
||||
) -> tuple[VmixMappingProfile, dict[str, Any]]:
|
||||
"""Create/update a hidden rebinding of a saved config for one concrete vMix project."""
|
||||
try:
|
||||
inventory = json.loads(device.project_inventory_json or "{}")
|
||||
except Exception:
|
||||
inventory = {}
|
||||
rebound, report = self._rebind_portable_mapping_fields(source_fields, inventory)
|
||||
if not rebound:
|
||||
raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи выбранного Mapping с вашим vMix")
|
||||
|
||||
target_fingerprint = str(device.project_fingerprint or "")[:64]
|
||||
runtime_name = self._runtime_mapping_profile_name(source.id, target_fingerprint)
|
||||
runtime = session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == runtime_name))
|
||||
now = _utcnow()
|
||||
|
||||
# Only one concrete mapping can drive a vMix project at a time.
|
||||
for other in session.scalars(
|
||||
select(VmixMappingProfile).where(and_(
|
||||
VmixMappingProfile.project_fingerprint == target_fingerprint,
|
||||
VmixMappingProfile.active.is_(True),
|
||||
))
|
||||
):
|
||||
if runtime is not None and other.id == runtime.id:
|
||||
continue
|
||||
other.active = False
|
||||
other.updated_by = user.login
|
||||
other.updated_at = now
|
||||
|
||||
if runtime is None:
|
||||
runtime = VmixMappingProfile(
|
||||
name=runtime_name,
|
||||
description=f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000],
|
||||
project_fingerprint=target_fingerprint,
|
||||
inventory_json=device.project_inventory_json or "{}",
|
||||
version=1,
|
||||
active=True,
|
||||
created_by=user.login,
|
||||
updated_by=user.login,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(runtime)
|
||||
session.flush()
|
||||
else:
|
||||
runtime.description = f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000]
|
||||
runtime.project_fingerprint = target_fingerprint
|
||||
runtime.inventory_json = device.project_inventory_json or "{}"
|
||||
runtime.version = int(runtime.version or 0) + 1
|
||||
runtime.active = True
|
||||
runtime.updated_by = user.login
|
||||
runtime.updated_at = now
|
||||
session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
|
||||
session.flush()
|
||||
|
||||
for index, item in enumerate(rebound):
|
||||
session.add(VmixMappingField(
|
||||
profile_id=runtime.id,
|
||||
graphic=str(item.get("graphic") or "")[:100],
|
||||
data_key=str(item.get("data_key") or "")[:200],
|
||||
vmix_input_key=str(item.get("vmix_input_key") or "")[:128],
|
||||
vmix_input_number=str(item.get("vmix_input_number") or "")[:32],
|
||||
vmix_input_title=str(item.get("vmix_input_title") or "")[:300],
|
||||
vmix_field=str(item.get("vmix_field") or "")[:300],
|
||||
field_type=str(item.get("field_type") or "text")[:32],
|
||||
rule_json=json.dumps(dict(item.get("rule") or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
|
||||
enabled=bool(item.get("enabled", True)),
|
||||
sort_order=index,
|
||||
))
|
||||
session.flush()
|
||||
return runtime, report
|
||||
|
||||
async def use_mapping_profile_for_user(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
|
||||
"""Use any saved Mapping config on the Agent selected for the user's current match."""
|
||||
requested = str(getattr(payload, "device_id", "") or "").strip()
|
||||
session_token = str(getattr(payload, "session_token", "") or "").strip()
|
||||
|
||||
with self.database.session() as session:
|
||||
source = session.get(VmixMappingProfile, profile_id)
|
||||
if source is None or self._runtime_mapping_source_id(source):
|
||||
raise HTTPException(status_code=404, detail="Mapping-конфиг не найден")
|
||||
|
||||
operator = None
|
||||
if session_token:
|
||||
operator = session.scalar(select(OperatorSession).where(and_(
|
||||
OperatorSession.session_token == session_token,
|
||||
OperatorSession.wfl_user_id == user.id,
|
||||
OperatorSession.status == "active",
|
||||
)))
|
||||
if operator is None:
|
||||
operator = session.scalar(
|
||||
select(OperatorSession)
|
||||
.where(and_(OperatorSession.wfl_user_id == user.id, OperatorSession.status == "active"))
|
||||
.order_by(desc(OperatorSession.id))
|
||||
)
|
||||
if not requested and operator is not None:
|
||||
requested = str(operator.vmix_device_uuid 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,
|
||||
VmixDevice.is_active_for_account.is_(True),
|
||||
)))
|
||||
else:
|
||||
candidates = 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))
|
||||
))
|
||||
device = candidates[0] if len(candidates) == 1 else None
|
||||
if device is not None:
|
||||
requested = str(device.device_uuid)
|
||||
|
||||
if device is None:
|
||||
raise HTTPException(status_code=409, detail="Не удалось определить ваш Agent. Выберите Agent для текущей сессии матча.")
|
||||
if not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
|
||||
raise HTTPException(status_code=409, detail="Ваш Agent ещё не передал структуру vMix")
|
||||
if not bool(device.vmix_connected):
|
||||
raise HTTPException(status_code=409, detail="На вашем Agent сейчас не подключён vMix")
|
||||
|
||||
# Make sure this concrete Agent is attached to the current match before sending values.
|
||||
assignment = await self.assign_current_match(user, device_id=requested)
|
||||
if assignment is None:
|
||||
raise HTTPException(status_code=409, detail="Сначала откройте матч в основном интерфейсе")
|
||||
|
||||
with self.database.session() as session:
|
||||
source = session.get(VmixMappingProfile, profile_id)
|
||||
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == requested))
|
||||
if source is None or device is None:
|
||||
raise HTTPException(status_code=404, detail="Mapping или Agent больше недоступен")
|
||||
source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
|
||||
source_fields = list(source_payload.get("fields") or [])
|
||||
target_fingerprint = str(device.project_fingerprint or "")
|
||||
|
||||
if str(source.project_fingerprint or "") == target_fingerprint:
|
||||
now = _utcnow()
|
||||
for other in session.scalars(select(VmixMappingProfile).where(and_(
|
||||
VmixMappingProfile.project_fingerprint == target_fingerprint,
|
||||
VmixMappingProfile.active.is_(True),
|
||||
VmixMappingProfile.id != source.id,
|
||||
))):
|
||||
other.active = False
|
||||
other.updated_by = user.login
|
||||
other.updated_at = now
|
||||
source.active = True
|
||||
source.updated_by = user.login
|
||||
source.updated_at = now
|
||||
runtime = source
|
||||
report = {
|
||||
"total": len(source_fields), "mapped": len(source_fields), "skipped": 0,
|
||||
"matched_by": {"key": len(source_fields), "title": 0, "number": 0},
|
||||
"skipped_items": [], "reused": True,
|
||||
}
|
||||
else:
|
||||
runtime, report = self._upsert_runtime_mapping_for_device(
|
||||
session, source=source, source_fields=source_fields, device=device, user=user,
|
||||
)
|
||||
runtime_id = runtime.id
|
||||
|
||||
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
|
||||
applied = await self.apply_mapping_to_device(requested, reason="mapping_config_selected")
|
||||
return {
|
||||
"ok": True,
|
||||
"device_id": requested,
|
||||
"profile": {"id": source_payload.get("id"), "name": source_payload.get("name"), "version": source_payload.get("version")},
|
||||
"runtime_profile_id": runtime_id,
|
||||
"report": report,
|
||||
"applied": applied,
|
||||
}
|
||||
|
||||
async def export_mapping_profile(self, profile_id: int) -> dict[str, Any]:
|
||||
with self.database.session() as session:
|
||||
profile = session.get(VmixMappingProfile, profile_id)
|
||||
@@ -2290,7 +2496,11 @@ class VmixAgentHub:
|
||||
|
||||
async def list_mapping_profiles(self) -> dict[str, Any]:
|
||||
with self.database.session() as session:
|
||||
rows = list(session.scalars(select(VmixMappingProfile).order_by(desc(VmixMappingProfile.updated_at), VmixMappingProfile.name)))
|
||||
rows = list(session.scalars(
|
||||
select(VmixMappingProfile)
|
||||
.where(~VmixMappingProfile.name.like("__AUTO_MAPPING__%"))
|
||||
.order_by(desc(VmixMappingProfile.updated_at), VmixMappingProfile.name)
|
||||
))
|
||||
items = [self._mapping_profile_payload(session, row, include_inventory=False, include_fields=False) for row in rows]
|
||||
for item, row in zip(items, rows):
|
||||
item["field_count"] = len(list(session.scalars(select(VmixMappingField.id).where(VmixMappingField.profile_id == row.id))))
|
||||
@@ -2426,6 +2636,11 @@ class VmixAgentHub:
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
|
||||
fingerprint = profile.project_fingerprint
|
||||
runtime_prefix = f"__AUTO_MAPPING__{profile_id}__%"
|
||||
runtime_profiles = list(session.scalars(select(VmixMappingProfile).where(VmixMappingProfile.name.like(runtime_prefix))))
|
||||
for runtime in runtime_profiles:
|
||||
session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
|
||||
session.delete(runtime)
|
||||
for field in list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == profile_id))):
|
||||
session.delete(field)
|
||||
session.delete(profile)
|
||||
@@ -2606,6 +2821,11 @@ class MappingFieldsReplacePayload(BaseModel):
|
||||
fields: list[MappingFieldPayload] = Field(default_factory=list, max_length=2000)
|
||||
|
||||
|
||||
class MappingProfileUsePayload(BaseModel):
|
||||
device_id: str = Field(default="", max_length=128)
|
||||
session_token: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
class MappingProfileCopyPayload(BaseModel):
|
||||
device_id: str = Field(min_length=6, max_length=128)
|
||||
name: str = Field(default="", max_length=200)
|
||||
@@ -2840,6 +3060,10 @@ def create_hockey_agent_router(
|
||||
async def mapping_profile_create(payload: MappingProfileCreatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
|
||||
return await hub.create_mapping_profile(payload, user)
|
||||
|
||||
@router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/use", dependencies=admin)
|
||||
async def mapping_profile_use(profile_id: int, payload: MappingProfileUsePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
|
||||
return await hub.use_mapping_profile_for_user(profile_id, payload, user)
|
||||
|
||||
@router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export", dependencies=admin)
|
||||
async def mapping_profile_export(profile_id: int) -> dict[str, Any]:
|
||||
return await hub.export_mapping_profile(profile_id)
|
||||
|
||||
Reference in New Issue
Block a user