копировать маппинг

This commit is contained in:
2026-08-19 18:00:04 +03:00
parent 14067f082c
commit 4453e23248
5 changed files with 573 additions and 19 deletions

View File

@@ -2546,6 +2546,65 @@ class VmixAgentHub:
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
return result
async def duplicate_mapping_profile(self, profile_id: int, user: HockeyUser) -> dict[str, Any]:
"""Create an independent editable copy of a visible Mapping config.
The copy keeps the source inventory, all field links and rules, but starts
inactive so it cannot unexpectedly replace the live Mapping for the same
vMix project. It can then be rescanned against another Agent/project and
edited without touching the original config.
"""
now = _utcnow()
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-конфиг не найден")
copy_name = self._portable_profile_name(
session,
f"{str(source.name or 'Mapping').strip()} — копия",
"Mapping — копия",
)
clone = VmixMappingProfile(
name=copy_name,
description=str(source.description or ""),
project_fingerprint=str(source.project_fingerprint or ""),
inventory_json=str(source.inventory_json or "{}"),
version=1,
active=False,
created_by=user.login,
updated_by=user.login,
created_at=now,
updated_at=now,
)
session.add(clone)
session.flush()
source_fields = list(session.scalars(
select(VmixMappingField)
.where(VmixMappingField.profile_id == source.id)
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
for item in source_fields:
session.add(VmixMappingField(
profile_id=clone.id,
graphic=str(item.graphic or "")[:100],
data_key=str(item.data_key or "")[:200],
vmix_input_key=str(item.vmix_input_key or "")[:128],
vmix_input_number=str(item.vmix_input_number or "")[:32],
vmix_input_title=str(item.vmix_input_title or "")[:300],
vmix_field=str(item.vmix_field or "")[:300],
field_type=str(item.field_type or "text")[:32],
rule_json=str(item.rule_json or "{}")[:12000],
enabled=bool(item.enabled),
sort_order=int(item.sort_order or 0),
))
session.flush()
result = self._mapping_profile_payload(session, clone, include_inventory=True, include_fields=True)
result["copied_from_profile_id"] = source.id
result["copied_fields"] = len(source_fields)
return result
async def update_mapping_profile(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
now = _utcnow()
with self.database.session() as session:
@@ -2570,7 +2629,66 @@ class VmixAgentHub:
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
return result
@classmethod
def _mapping_inventory_additions(cls, old_inventory: dict[str, Any], new_inventory: dict[str, Any]) -> dict[str, int]:
"""Count newly discovered Inputs/fields while tolerating stable-key changes.
Existing Inputs are resolved with the same conservative identity order used
by portable Mapping rebinding: key, unique title, then unique number. This is
only a UI report; it never changes or guesses Mapping links.
"""
old_inputs = old_inventory.get("inputs") if isinstance(old_inventory, dict) and isinstance(old_inventory.get("inputs"), list) else []
new_inputs = new_inventory.get("inputs") if isinstance(new_inventory, dict) and isinstance(new_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 new_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)
used: set[int] = set()
added_fields = 0
for old in old_inputs:
if not isinstance(old, dict):
continue
target = None
key = str(old.get("key") or "").strip()
title = cls._portable_text(old.get("title"))
number = str(old.get("number") or "").strip()
if key:
target = by_key.get(key)
if target is None and title and len(by_title.get(title, [])) == 1:
target = by_title[title][0]
if target is None and number and len(by_number.get(number, [])) == 1:
target = by_number[number][0]
if target is None:
continue
used.add(id(target))
old_names = {cls._portable_text(field.get("name")) for field in (old.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()}
new_names = {cls._portable_text(field.get("name")) for field in (target.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()}
added_fields += len(new_names - old_names)
added_inputs = [item for item in new_inputs if isinstance(item, dict) and id(item) not in used]
added_fields += sum(len([field for field in (item.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()]) for item in added_inputs)
return {"new_inputs": len(added_inputs), "new_fields": added_fields}
async def refresh_mapping_inventory(self, profile_id: int, device_id: str, user: HockeyUser) -> dict[str, Any]:
"""Refresh vMix structure without resetting existing Mapping links.
New titles/fields only expand the inventory. Existing links are rebound to
the freshly scanned project by Input key -> unique title -> unique number,
and the exact GT field name must still exist. A link that cannot currently
be resolved is kept unchanged in the database instead of being deleted.
"""
device_id = self.normalise_device_id(device_id)
now = _utcnow()
with self.database.session() as session:
@@ -2582,14 +2700,94 @@ class VmixAgentHub:
raise HTTPException(status_code=409, detail="Устройство не передало структуру vMix")
conflicting = session.scalar(select(VmixMappingProfile).where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.id != row.id, VmixMappingProfile.active.is_(True))))
if conflicting is not None:
raise HTTPException(status_code=409, detail=f"Эта структура уже используется mapping «{conflicting.name}»")
# Build 74 may have created a hidden runtime adaptation of this same
# visible config for the Agent. An explicit rescan means the admin now
# wants the visible config itself to adopt that concrete vMix structure.
if self._runtime_mapping_source_id(conflicting) == row.id:
conflicting.active = False
conflicting.updated_by = user.login
conflicting.updated_at = now
elif row.active:
raise HTTPException(status_code=409, detail=f"Эта структура уже используется mapping «{conflicting.name}»")
# An inactive visible copy is a draft. It may adopt the same vMix
# inventory as another live config so the admin can reuse the links,
# rescan against another project and edit the copy independently.
try:
old_inventory = json.loads(row.inventory_json or "{}")
if not isinstance(old_inventory, dict):
old_inventory = {}
except Exception:
old_inventory = {}
try:
new_inventory = json.loads(device.project_inventory_json or "{}")
if not isinstance(new_inventory, dict):
new_inventory = {}
except Exception:
new_inventory = {}
link_rows = list(session.scalars(
select(VmixMappingField)
.where(VmixMappingField.profile_id == profile_id)
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
matched_by = {"key": 0, "title": 0, "number": 0}
preserved = 0
unresolved = 0
unresolved_items: list[dict[str, Any]] = []
for field_row in link_rows:
raw = {
"graphic": field_row.graphic,
"data_key": field_row.data_key,
"vmix_input_key": field_row.vmix_input_key,
"vmix_input_number": field_row.vmix_input_number,
"vmix_input_title": field_row.vmix_input_title,
"vmix_field": field_row.vmix_field,
"field_type": field_row.field_type,
"rule": _mapping_rule_payload(field_row.rule_json),
"enabled": field_row.enabled,
}
rebound, report = self._rebind_portable_mapping_fields([raw], new_inventory)
if rebound:
target = rebound[0]
field_row.vmix_input_key = str(target.get("vmix_input_key") or "")[:128]
field_row.vmix_input_number = str(target.get("vmix_input_number") or "")[:32]
field_row.vmix_input_title = str(target.get("vmix_input_title") or "")[:300]
field_row.vmix_field = str(target.get("vmix_field") or field_row.vmix_field)[:300]
preserved += 1
for method in matched_by:
matched_by[method] += int((report.get("matched_by") or {}).get(method, 0) or 0)
else:
# Keep the old link. This protects operator work from a temporary
# incomplete scan and lets it recover automatically if the target
# returns on the next refresh.
unresolved += 1
if len(unresolved_items) < 100:
skipped = (report.get("skipped_items") or [{}])[0]
unresolved_items.append({
"data_key": field_row.data_key,
"input": field_row.vmix_input_title or field_row.vmix_input_key or field_row.vmix_input_number,
"vmix_field": field_row.vmix_field,
"reason": skipped.get("reason", "not_found"),
})
additions = self._mapping_inventory_additions(old_inventory, new_inventory)
old_fingerprint = row.project_fingerprint
row.project_fingerprint = device.project_fingerprint
row.inventory_json = device.project_inventory_json
row.version = int(row.version or 0) + 1
row.updated_by = user.login
row.updated_at = now
session.flush()
result = self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)
result["refresh_report"] = {
"total_links": len(link_rows),
"preserved": preserved,
"unresolved": unresolved,
"matched_by": matched_by,
"unresolved_items": unresolved_items,
**additions,
}
if old_fingerprint and old_fingerprint != result["project_fingerprint"]:
await self._broadcast_mapping_for_fingerprint(old_fingerprint)
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
@@ -3064,6 +3262,10 @@ def create_hockey_agent_router(
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.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/duplicate", dependencies=admin)
async def mapping_profile_duplicate(profile_id: int, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.duplicate_mapping_profile(profile_id, 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)