экспорт маппинга

This commit is contained in:
2026-08-19 17:32:13 +03:00
parent c643a183d8
commit 614a61168a
17 changed files with 1012 additions and 35 deletions

View File

@@ -1986,6 +1986,308 @@ class VmixAgentHub:
"inventory": inventory,
}
@staticmethod
def _portable_text(value: Any) -> str:
return " ".join(str(value or "").strip().casefold().split())
@classmethod
def _rebind_portable_mapping_fields(
cls,
fields: list[dict[str, Any]],
inventory: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Rebind saved Mapping targets to another vMix project inventory.
Resolution order is deliberately conservative: stable Input key first,
then a unique title, then a unique positional number as a legacy fallback.
The selected vMix field itself must also exist on the resolved Input.
"""
inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
by_key: dict[str, dict[str, Any]] = {}
by_title: dict[str, list[dict[str, Any]]] = {}
by_number: dict[str, list[dict[str, Any]]] = {}
for item in inputs:
if not isinstance(item, dict):
continue
key = str(item.get("key") or "").strip()
title = cls._portable_text(item.get("title"))
number = str(item.get("number") or "").strip()
if key:
by_key[key] = item
if title:
by_title.setdefault(title, []).append(item)
if number:
by_number.setdefault(number, []).append(item)
rebound: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
matched_by = {"key": 0, "title": 0, "number": 0}
seen: set[tuple[str, str, str]] = set()
for index, raw in enumerate((fields or [])[:2000]):
if not isinstance(raw, dict):
skipped.append({"index": index, "reason": "invalid_field"})
continue
data_key = str(raw.get("data_key") or "").strip()
vmix_field = str(raw.get("vmix_field") or "").strip()
if not data_key or not vmix_field:
skipped.append({"index": index, "data_key": data_key, "vmix_field": vmix_field, "reason": "missing_mapping_key"})
continue
source_key = str(raw.get("vmix_input_key") or "").strip()
source_title = str(raw.get("vmix_input_title") or "").strip()
source_number = str(raw.get("vmix_input_number") or "").strip()
target = by_key.get(source_key) if source_key else None
resolution = "key" if target is not None else ""
if target is None and source_title:
title_matches = by_title.get(cls._portable_text(source_title), [])
if len(title_matches) == 1:
target = title_matches[0]
resolution = "title"
elif len(title_matches) > 1:
skipped.append({"index": index, "data_key": data_key, "input": source_title, "vmix_field": vmix_field, "reason": "ambiguous_input_title"})
continue
if target is None and source_number:
number_matches = by_number.get(source_number, [])
if len(number_matches) == 1:
target = number_matches[0]
resolution = "number"
elif len(number_matches) > 1:
skipped.append({"index": index, "data_key": data_key, "input": source_number, "vmix_field": vmix_field, "reason": "ambiguous_input_number"})
continue
if target is None:
skipped.append({"index": index, "data_key": data_key, "input": source_title or source_key or source_number, "vmix_field": vmix_field, "reason": "input_not_found"})
continue
target_fields = target.get("fields") if isinstance(target.get("fields"), list) else []
exact = next((f for f in target_fields if isinstance(f, dict) and str(f.get("name") or "").strip() == vmix_field), None)
selected_name = vmix_field
if exact is None:
folded = cls._portable_text(vmix_field)
folded_matches = [f for f in target_fields if isinstance(f, dict) and cls._portable_text(f.get("name")) == folded]
if len(folded_matches) == 1:
selected_name = str(folded_matches[0].get("name") or "").strip()
else:
skipped.append({
"index": index, "data_key": data_key,
"input": str(target.get("title") or target.get("key") or target.get("number") or ""),
"vmix_field": vmix_field,
"reason": "field_not_found" if not folded_matches else "ambiguous_field",
})
continue
target_key = str(target.get("key") or "").strip()
target_title = str(target.get("title") or "").strip()
target_number = str(target.get("number") or "").strip()
dedupe = (data_key, target_key or target_title or target_number, selected_name)
if dedupe in seen:
skipped.append({"index": index, "data_key": data_key, "input": target_title, "vmix_field": selected_name, "reason": "duplicate_target"})
continue
seen.add(dedupe)
matched_by[resolution or "title"] = matched_by.get(resolution or "title", 0) + 1
rebound.append({
"graphic": str(raw.get("graphic") or "")[:100],
"data_key": data_key[:200],
"vmix_input_key": target_key[:128],
"vmix_input_number": target_number[:32],
"vmix_input_title": target_title[:300],
"vmix_field": selected_name[:300],
"field_type": str(raw.get("field_type") or "text")[:32],
"rule": dict(raw.get("rule") or {}) if isinstance(raw.get("rule"), dict) else {},
"enabled": bool(raw.get("enabled", True)),
"sort_order": len(rebound),
})
return rebound, {
"total": min(len(fields or []), 2000),
"mapped": len(rebound),
"skipped": len(skipped),
"matched_by": matched_by,
"skipped_items": skipped[:200],
}
@staticmethod
def _portable_profile_name(session: Any, desired: str, fallback: str) -> str:
base = str(desired or fallback or "Imported Mapping").strip()[:200] or "Imported Mapping"
if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == base)) is None:
return base
stem = base[:185]
for number in range(2, 1000):
candidate = f"{stem} ({number})"[:200]
if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == candidate)) is None:
return candidate
return f"{stem} {secrets.token_hex(3)}"[:200]
def _create_portable_profile_for_device(
self,
session: Any,
*,
device: VmixDevice,
fields: list[dict[str, Any]],
name: str,
description: str,
user: HockeyUser,
replace_existing: bool,
) -> tuple[VmixMappingProfile, dict[str, Any]]:
try:
inventory = json.loads(device.project_inventory_json or "{}")
except Exception:
inventory = {}
rebound, report = self._rebind_portable_mapping_fields(fields, inventory)
if not rebound:
raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи Mapping с выбранным vMix")
conflict = session.scalar(
select(VmixMappingProfile)
.where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.active.is_(True)))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
)
if conflict is not None:
if not replace_existing:
raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{conflict.name}». Разрешите замену при переносе.")
conflict.active = False
conflict.updated_by = user.login
conflict.updated_at = _utcnow()
now = _utcnow()
profile = VmixMappingProfile(
name=self._portable_profile_name(session, name, "Imported Mapping"),
description=str(description or "")[:4000],
project_fingerprint=str(device.project_fingerprint or "")[:64],
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(profile)
session.flush()
for index, item in enumerate(rebound):
session.add(VmixMappingField(
profile_id=profile.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 profile, report
async def export_mapping_profile(self, profile_id: int) -> dict[str, Any]:
with self.database.session() as session:
profile = session.get(VmixMappingProfile, profile_id)
if profile is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
payload = self._mapping_profile_payload(session, profile, include_inventory=True, include_fields=True)
fields = []
for row in payload.get("fields") or []:
fields.append({key: value for key, value in row.items() if key != "id"})
inventory = payload.get("inventory") if isinstance(payload.get("inventory"), dict) else {}
return {
"format": "hockey-vmix-mapping",
"schema_version": 1,
"exported_at": _utcnow().isoformat(),
"profile": {
"name": payload.get("name") or "",
"description": payload.get("description") or "",
"source_project_fingerprint": payload.get("project_fingerprint") or "",
"source_inventory": {
"input_count": inventory.get("input_count", 0),
"field_count": inventory.get("field_count", 0),
"vmix_version": inventory.get("vmix_version", ""),
},
"fields": fields,
},
}
async def copy_mapping_profile_to_device(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
device_id = self.normalise_device_id(payload.device_id)
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
if source is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
target_fingerprint = str(device.project_fingerprint or "")
if str(source.project_fingerprint or "") == target_fingerprint:
other = session.scalar(
select(VmixMappingProfile)
.where(and_(
VmixMappingProfile.project_fingerprint == target_fingerprint,
VmixMappingProfile.active.is_(True),
VmixMappingProfile.id != source.id,
))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
)
if other is not None and not bool(payload.replace_existing):
raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{other.name}». Разрешите замену при переносе.")
if other is not None:
other.active = False
other.updated_by = user.login
other.updated_at = _utcnow()
source.active = True
source.updated_by = user.login
source.updated_at = _utcnow()
result_profile = self._mapping_profile_payload(session, source, include_inventory=True, include_fields=True)
report = {"total": len(source_payload.get("fields") or []), "mapped": len(source_payload.get("fields") or []), "skipped": 0, "matched_by": {"key": len(source_payload.get("fields") or []), "title": 0, "number": 0}, "skipped_items": [], "reused": True}
else:
fallback_name = f"{source.name} · {device.name or device.hostname or device.device_uuid}"
created, report = self._create_portable_profile_for_device(
session, device=device, fields=list(source_payload.get("fields") or []),
name=str(payload.name or "").strip() or fallback_name, description=source.description, user=user,
replace_existing=bool(payload.replace_existing),
)
result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
applied = None
if bool(payload.apply_now):
applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_copy")
return {"ok": True, "profile": result_profile, "report": report, "applied": applied}
async def import_mapping_profile_to_device(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
device_id = self.normalise_device_id(payload.device_id)
document = dict(payload.document or {})
if document.get("format") not in {None, "", "hockey-vmix-mapping"}:
raise HTTPException(status_code=422, detail="Это не файл Hockey vMix Mapping")
try:
schema_version = int(document.get("schema_version") or 1)
except (TypeError, ValueError):
raise HTTPException(status_code=422, detail="Некорректная версия файла Mapping")
if schema_version != 1:
raise HTTPException(status_code=422, detail="Версия файла Mapping пока не поддерживается")
profile_doc = document.get("profile") if isinstance(document.get("profile"), dict) else document
fields = profile_doc.get("fields") if isinstance(profile_doc, dict) and isinstance(profile_doc.get("fields"), list) else []
if not fields:
raise HTTPException(status_code=422, detail="В файле Mapping нет связей")
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
target_fingerprint = str(device.project_fingerprint or "")
imported_name = str(payload.name or "").strip() or str(profile_doc.get("name") or "Imported Mapping").strip()
created, report = self._create_portable_profile_for_device(
session, device=device, fields=fields, name=imported_name,
description=str(profile_doc.get("description") or ""), user=user,
replace_existing=bool(payload.replace_existing),
)
result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
applied = None
if bool(payload.apply_now):
applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_import")
return {"ok": True, "profile": result_profile, "report": report, "applied": applied}
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)))
@@ -2304,6 +2606,21 @@ class MappingFieldsReplacePayload(BaseModel):
fields: list[MappingFieldPayload] = Field(default_factory=list, max_length=2000)
class MappingProfileCopyPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
name: str = Field(default="", max_length=200)
replace_existing: bool = False
apply_now: bool = True
class MappingPortableImportPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
document: dict[str, Any] = Field(default_factory=dict)
name: str = Field(default="", max_length=200)
replace_existing: bool = False
apply_now: bool = True
class ContextVariablePayload(BaseModel):
key: str = Field(min_length=1, max_length=128)
label: str = Field(min_length=1, max_length=200)
@@ -2523,6 +2840,18 @@ 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.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)
@router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/copy-to-device", dependencies=admin)
async def mapping_profile_copy_to_device(profile_id: int, payload: MappingProfileCopyPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.copy_mapping_profile_to_device(profile_id, payload, user)
@router.post("/api/hockey/admin/vmix-mapping/import", dependencies=admin)
async def mapping_profile_import(payload: MappingPortableImportPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.import_mapping_profile_to_device(payload, user)
@router.put("/api/hockey/admin/vmix-mapping/profiles/{profile_id}", dependencies=admin)
async def mapping_profile_update(profile_id: int, payload: MappingProfileUpdatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.update_mapping_profile(profile_id, payload, user)