копировать маппинг
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user