bulid 63
This commit is contained in:
80
broadcast_settings/README.md
Normal file
80
broadcast_settings/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# broadcast_settings
|
||||
|
||||
Отдельная переносимая библиотека для проектов ТВ-графики/vMix.
|
||||
|
||||
Она отвечает за:
|
||||
|
||||
- загрузку и сохранение `settings/settings.json`;
|
||||
- загрузку и сохранение `settings/vmix_json.json`;
|
||||
- загрузку справочника функций `settings/vmix_functions.json`;
|
||||
- сохранение выбранного соревнования/раунда или другой активной сущности;
|
||||
- backup настроек перед импортом;
|
||||
- универсальный импорт настроек из `.json`/`.zip` другого проекта;
|
||||
- миграцию старого формата лёгкой атлетики `vmixColumns`, `vmixTransforms`, `vmixRows`;
|
||||
- поиск и выдачу нужного vMix JSON-пресета.
|
||||
|
||||
## Подключение в другом проекте
|
||||
|
||||
Скопируй папку `broadcast_settings` в корень проекта.
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from broadcast_settings import settings as settings_lib
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
|
||||
settings_lib.configure(
|
||||
project_dir=PROJECT_DIR,
|
||||
app_dir=APP_DIR,
|
||||
)
|
||||
|
||||
settings = settings_lib.read_settings()
|
||||
vmix_json = settings_lib.read_vmix_json()
|
||||
functions = settings_lib.read_vmix_functions()
|
||||
```
|
||||
|
||||
Можно передать свои дефолтные настройки:
|
||||
|
||||
```python
|
||||
settings_lib.configure(
|
||||
project_dir=PROJECT_DIR,
|
||||
app_dir=APP_DIR,
|
||||
default_settings=MY_DEFAULT_SETTINGS,
|
||||
default_vmix_json=MY_DEFAULT_VMIX_JSON,
|
||||
default_vmix_functions=MY_DEFAULT_VMIX_FUNCTIONS,
|
||||
)
|
||||
```
|
||||
|
||||
## Главные функции
|
||||
|
||||
```python
|
||||
read_settings()
|
||||
write_settings(data)
|
||||
read_vmix_json()
|
||||
write_vmix_json(data)
|
||||
read_vmix_functions()
|
||||
write_vmix_functions(data)
|
||||
read_selection()
|
||||
write_selection(selection)
|
||||
get_vmix_config(key)
|
||||
settings_meta()
|
||||
make_settings_backup(reason)
|
||||
preview_settings_import(data, file_name)
|
||||
apply_settings_import(preview, ...)
|
||||
```
|
||||
|
||||
В golf-проекте файл `app/settings_manager.py` теперь является только тонким адаптером,
|
||||
а вся логика находится здесь.
|
||||
|
||||
## Portable joins / merge sources
|
||||
|
||||
The reusable file `broadcast_settings/joins.py` provides a generic JSON join engine:
|
||||
|
||||
```python
|
||||
from broadcast_settings.joins import apply_join_rules
|
||||
|
||||
rows = apply_join_rules(base_rows, joins_config, source_resolver)
|
||||
```
|
||||
|
||||
A project only needs to provide `source_resolver(join)`, which returns JSON/list rows for the requested source. The join config itself is portable and can be stored inside each vMix JSON config under the `joins` key.
|
||||
16
broadcast_settings/__init__.py
Normal file
16
broadcast_settings/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Portable settings library for broadcast/vMix projects.
|
||||
|
||||
This package contains reusable settings storage, vMix JSON presets,
|
||||
formula function descriptions, backup, import and migration helpers.
|
||||
|
||||
Typical project integration:
|
||||
|
||||
from pathlib import Path
|
||||
from broadcast_settings import settings as settings_lib
|
||||
|
||||
settings_lib.configure(project_dir=Path(__file__).resolve().parent.parent)
|
||||
|
||||
Then use settings_lib.read_settings(), settings_lib.read_vmix_json(), etc.
|
||||
"""
|
||||
|
||||
from .settings import * # noqa: F401,F403
|
||||
305
broadcast_settings/joins.py
Normal file
305
broadcast_settings/joins.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable
|
||||
|
||||
SourceResolver = Callable[[dict[str, Any]], Any]
|
||||
|
||||
LIST_KEYS = (
|
||||
"rows", "players", "items", "data", "results", "scores", "tournaments",
|
||||
"rounds", "list", "records", "entries",
|
||||
)
|
||||
|
||||
|
||||
def _safe_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_nested_value(obj: Any, path: Any, default: Any = "") -> Any:
|
||||
"""Read values by direct key, dotted path, or bracket-ish path.
|
||||
|
||||
Examples:
|
||||
- player_id
|
||||
- raw.player.id
|
||||
- holes.0.score
|
||||
- holes[0].score
|
||||
"""
|
||||
if path in (None, ""):
|
||||
return default
|
||||
key = str(path).strip()
|
||||
if not key:
|
||||
return default
|
||||
|
||||
if isinstance(obj, dict) and key in obj:
|
||||
value = obj.get(key)
|
||||
return default if value is None else value
|
||||
|
||||
tokens = [p for p in re.split(r"\.|\[|\]", key.replace("/", ".")) if p not in ("", None)]
|
||||
cur = obj
|
||||
for token in tokens:
|
||||
if isinstance(cur, dict):
|
||||
if token in cur:
|
||||
cur = cur[token]
|
||||
else:
|
||||
return default
|
||||
elif isinstance(cur, list):
|
||||
try:
|
||||
cur = cur[int(token)]
|
||||
except Exception:
|
||||
return default
|
||||
else:
|
||||
return default
|
||||
return default if cur is None else cur
|
||||
|
||||
|
||||
def flatten_dict(obj: Any, prefix: str = "") -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
clean_key = str(key)
|
||||
next_prefix = f"{prefix}_{clean_key}" if prefix else clean_key
|
||||
if isinstance(value, dict):
|
||||
out.update(flatten_dict(value, next_prefix))
|
||||
elif isinstance(value, list):
|
||||
if all(not isinstance(x, (dict, list)) for x in value):
|
||||
out[next_prefix] = " | ".join(_safe_text(x) for x in value)
|
||||
else:
|
||||
out[next_prefix] = value
|
||||
else:
|
||||
out[next_prefix] = value
|
||||
return out
|
||||
|
||||
|
||||
def as_rows(data: Any) -> list[dict[str, Any]]:
|
||||
"""Convert almost any JSON response into a list of row dictionaries."""
|
||||
candidate: Any = data
|
||||
if isinstance(data, dict):
|
||||
for key in LIST_KEYS:
|
||||
value = data.get(key)
|
||||
if isinstance(value, list):
|
||||
candidate = value
|
||||
break
|
||||
else:
|
||||
# Fallback: choose the largest list of dicts in the object.
|
||||
lists: list[list[Any]] = []
|
||||
|
||||
def walk(obj: Any) -> None:
|
||||
if isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
walk(value)
|
||||
elif isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj):
|
||||
lists.append(obj)
|
||||
|
||||
walk(data)
|
||||
if lists:
|
||||
candidate = max(lists, key=len)
|
||||
else:
|
||||
candidate = [data]
|
||||
|
||||
if isinstance(candidate, dict):
|
||||
candidate = [candidate]
|
||||
if not isinstance(candidate, list):
|
||||
return []
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in candidate:
|
||||
if isinstance(item, dict):
|
||||
flat = flatten_dict(item)
|
||||
row = dict(item)
|
||||
for key, value in flat.items():
|
||||
row.setdefault(key, value)
|
||||
rows.append(row)
|
||||
else:
|
||||
rows.append({"value": item})
|
||||
return rows
|
||||
|
||||
|
||||
def _normalize_match_value(value: Any, *, case_sensitive: bool = False, trim: bool = True) -> str:
|
||||
text = _safe_text(value)
|
||||
if trim:
|
||||
text = text.strip()
|
||||
if not case_sensitive:
|
||||
text = text.lower()
|
||||
return text
|
||||
|
||||
|
||||
def _match_pairs(join: dict[str, Any]) -> list[dict[str, str]]:
|
||||
pairs = join.get("match") or join.get("matches") or []
|
||||
if isinstance(pairs, list) and pairs:
|
||||
result = []
|
||||
for item in pairs:
|
||||
if isinstance(item, dict):
|
||||
left = item.get("left") or item.get("base") or item.get("from") or item.get("local")
|
||||
right = item.get("right") or item.get("source") or item.get("to") or item.get("remote")
|
||||
if left and right:
|
||||
result.append({"left": str(left), "right": str(right)})
|
||||
if result:
|
||||
return result
|
||||
|
||||
left_keys = join.get("left_keys") or join.get("left") or join.get("base_keys") or ""
|
||||
right_keys = join.get("right_keys") or join.get("right") or join.get("source_keys") or ""
|
||||
if isinstance(left_keys, str):
|
||||
left_list = [x.strip() for x in re.split(r"[,;\n]+", left_keys) if x.strip()]
|
||||
else:
|
||||
left_list = [str(x).strip() for x in left_keys or [] if str(x).strip()]
|
||||
if isinstance(right_keys, str):
|
||||
right_list = [x.strip() for x in re.split(r"[,;\n]+", right_keys) if x.strip()]
|
||||
else:
|
||||
right_list = [str(x).strip() for x in right_keys or [] if str(x).strip()]
|
||||
return [{"left": l, "right": r} for l, r in zip(left_list, right_list)]
|
||||
|
||||
|
||||
def _field_rules(join: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
fields = join.get("fields") or join.get("output_fields") or []
|
||||
if isinstance(fields, str):
|
||||
parsed: list[dict[str, Any]] = []
|
||||
for line in re.split(r"[\n;]+", fields):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "=" in line:
|
||||
key, source = line.split("=", 1)
|
||||
elif ":" in line:
|
||||
key, source = line.split(":", 1)
|
||||
else:
|
||||
key, source = line, line
|
||||
parsed.append({"key": key.strip(), "source": source.strip()})
|
||||
fields = parsed
|
||||
result: list[dict[str, Any]] = []
|
||||
for field in fields:
|
||||
if isinstance(field, str):
|
||||
result.append({"key": field, "source": field})
|
||||
elif isinstance(field, dict):
|
||||
key = field.get("key") or field.get("name") or field.get("target") or field.get("output")
|
||||
source = field.get("source") or field.get("from") or field.get("path") or key
|
||||
if key:
|
||||
result.append({
|
||||
"key": str(key),
|
||||
"source": str(source or key),
|
||||
"default": field.get("default", ""),
|
||||
"enabled": field.get("enabled", True),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _row_key(row: dict[str, Any], fields: list[str], *, case_sensitive: bool, trim: bool) -> tuple[str, ...]:
|
||||
return tuple(_normalize_match_value(get_nested_value(row, field), case_sensitive=case_sensitive, trim=trim) for field in fields)
|
||||
|
||||
|
||||
def apply_join_rules(
|
||||
base_rows: list[dict[str, Any]],
|
||||
joins: list[dict[str, Any]] | None,
|
||||
source_resolver: SourceResolver,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge data from other JSON sources into base rows.
|
||||
|
||||
Config example::
|
||||
|
||||
{
|
||||
"enabled": true,
|
||||
"title": "Подтянуть регион",
|
||||
"source_type": "url", # scores | tournament | round | vmix_json | url | file
|
||||
"source_key": "https://.../x.json", # or another vMix JSON key
|
||||
"match": [{"left": "player_id", "right": "id"}],
|
||||
"fields": [{"key": "region", "source": "region"}],
|
||||
"multiple": "first", # first | last | all | count
|
||||
"separator": " | ",
|
||||
"prefix": "",
|
||||
"case_sensitive": false,
|
||||
"trim": true
|
||||
}
|
||||
"""
|
||||
if not joins:
|
||||
return base_rows
|
||||
|
||||
result = [dict(row) for row in base_rows]
|
||||
|
||||
for join in joins:
|
||||
if not isinstance(join, dict) or join.get("enabled", True) is False:
|
||||
continue
|
||||
pairs = _match_pairs(join)
|
||||
if not pairs:
|
||||
continue
|
||||
fields = _field_rules(join)
|
||||
mode = str(join.get("multiple") or join.get("mode") or "first").lower()
|
||||
separator = _safe_text(join.get("separator", " | "))
|
||||
prefix = _safe_text(join.get("prefix", ""))
|
||||
case_sensitive = bool(join.get("case_sensitive", False))
|
||||
trim = bool(join.get("trim", True))
|
||||
|
||||
try:
|
||||
source_rows = as_rows(source_resolver(join))
|
||||
except Exception as exc:
|
||||
if join.get("required"):
|
||||
raise
|
||||
# Keep a lightweight error field for debugging without breaking vMix.
|
||||
error_key = prefix + _safe_text(join.get("error_key") or "join_error")
|
||||
for row in result:
|
||||
row.setdefault(error_key, str(exc))
|
||||
continue
|
||||
|
||||
if not source_rows:
|
||||
continue
|
||||
|
||||
right_fields = [p["right"] for p in pairs]
|
||||
left_fields = [p["left"] for p in pairs]
|
||||
index: dict[tuple[str, ...], list[dict[str, Any]]] = {}
|
||||
for source_row in source_rows:
|
||||
key = _row_key(source_row, right_fields, case_sensitive=case_sensitive, trim=trim)
|
||||
# Empty composite keys are usually bad matches; skip them.
|
||||
if not any(key):
|
||||
continue
|
||||
index.setdefault(key, []).append(source_row)
|
||||
|
||||
count_key = prefix + _safe_text(join.get("count_key") or "join_count")
|
||||
for row in result:
|
||||
key = _row_key(row, left_fields, case_sensitive=case_sensitive, trim=trim)
|
||||
matches = index.get(key, []) if any(key) else []
|
||||
|
||||
if mode == "count":
|
||||
row[count_key] = len(matches)
|
||||
continue
|
||||
if not matches:
|
||||
for field in fields:
|
||||
if field.get("enabled", True) is not False:
|
||||
row.setdefault(prefix + field["key"], field.get("default", ""))
|
||||
continue
|
||||
|
||||
selected_rows = matches
|
||||
if mode == "last":
|
||||
selected_rows = [matches[-1]]
|
||||
elif mode not in {"all", "list"}:
|
||||
selected_rows = [matches[0]]
|
||||
|
||||
for field in fields:
|
||||
if field.get("enabled", True) is False:
|
||||
continue
|
||||
out_key = prefix + field["key"]
|
||||
source_path = field.get("source") or field["key"]
|
||||
default = field.get("default", "")
|
||||
values = [get_nested_value(src, source_path, default) for src in selected_rows]
|
||||
if mode in {"all", "list"}:
|
||||
row[out_key] = separator.join(_safe_text(v) for v in values if v not in (None, ""))
|
||||
else:
|
||||
row[out_key] = values[0] if values and values[0] not in (None, "") else default
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def compact_join_for_storage(join: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize UI payload before saving it to portable config."""
|
||||
data = deepcopy(join)
|
||||
data.setdefault("enabled", True)
|
||||
data.setdefault("source_type", "scores")
|
||||
data.setdefault("source_key", "")
|
||||
data.setdefault("multiple", "first")
|
||||
data.setdefault("separator", " | ")
|
||||
data.setdefault("prefix", "")
|
||||
data.setdefault("case_sensitive", False)
|
||||
data.setdefault("trim", True)
|
||||
data["match"] = _match_pairs(data)
|
||||
data["fields"] = _field_rules(data)
|
||||
return data
|
||||
1101
broadcast_settings/settings.py
Normal file
1101
broadcast_settings/settings.py
Normal file
File diff suppressed because it is too large
Load Diff
2121
broadcast_settings/transform.py
Normal file
2121
broadcast_settings/transform.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user