переделал систему подключения агента к веб-сокету
This commit is contained in:
28
agent.py
28
agent.py
@@ -36,12 +36,14 @@ def read_vmix_dynamic_values():
|
|||||||
session_token = get_text("./dynamic/value1")
|
session_token = get_text("./dynamic/value1")
|
||||||
match_id = get_text("./dynamic/value2")
|
match_id = get_text("./dynamic/value2")
|
||||||
group_name = get_text("./dynamic/value3")
|
group_name = get_text("./dynamic/value3")
|
||||||
|
operator_login = get_text("./dynamic/value4")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
"match_id": int(match_id) if match_id and match_id.isdigit() else None,
|
"match_id": int(match_id) if match_id and match_id.isdigit() else None,
|
||||||
"group_name": group_name,
|
"group_name": group_name,
|
||||||
|
"operator_login": operator_login,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -50,6 +52,7 @@ def read_vmix_dynamic_values():
|
|||||||
"session_token": None,
|
"session_token": None,
|
||||||
"match_id": None,
|
"match_id": None,
|
||||||
"group_name": None,
|
"group_name": None,
|
||||||
|
"operator_login": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def execute_vmix_command(path: str):
|
def execute_vmix_command(path: str):
|
||||||
@@ -82,7 +85,7 @@ async def wait_for_session():
|
|||||||
print("[agent] waiting for vMix session...", data)
|
print("[agent] waiting for vMix session...", data)
|
||||||
await asyncio.sleep(POLL_INTERVAL)
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
def build_ws_url(session_token: str, match_id: int | None, group_name: str | None):
|
def build_ws_url(session_token: str, match_id: int | None, group_name: str | None, operator_login: str | None):
|
||||||
params = {
|
params = {
|
||||||
"client_id": CLIENT_ID,
|
"client_id": CLIENT_ID,
|
||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
@@ -91,6 +94,8 @@ def build_ws_url(session_token: str, match_id: int | None, group_name: str | Non
|
|||||||
params["match_id"] = match_id
|
params["match_id"] = match_id
|
||||||
if group_name:
|
if group_name:
|
||||||
params["group_name"] = group_name
|
params["group_name"] = group_name
|
||||||
|
if operator_login:
|
||||||
|
params["operator_name"] = operator_login
|
||||||
return f"{WS_BASE}?{urlencode(params)}"
|
return f"{WS_BASE}?{urlencode(params)}"
|
||||||
|
|
||||||
async def ping_loop(ws):
|
async def ping_loop(ws):
|
||||||
@@ -100,6 +105,9 @@ async def ping_loop(ws):
|
|||||||
|
|
||||||
async def run_agent():
|
async def run_agent():
|
||||||
current_session = None
|
current_session = None
|
||||||
|
current_match_id = None
|
||||||
|
current_group_name = None
|
||||||
|
current_operator_login = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
vmix_data = await wait_for_session()
|
vmix_data = await wait_for_session()
|
||||||
@@ -107,12 +115,13 @@ async def run_agent():
|
|||||||
session_token = vmix_data["session_token"]
|
session_token = vmix_data["session_token"]
|
||||||
match_id = vmix_data["match_id"]
|
match_id = vmix_data["match_id"]
|
||||||
group_name = vmix_data["group_name"]
|
group_name = vmix_data["group_name"]
|
||||||
|
operator_login = vmix_data["operator_login"]
|
||||||
|
|
||||||
if current_session != session_token:
|
if current_session != session_token:
|
||||||
print(f"[agent] found session: {session_token}")
|
print(f"[agent] found session: {session_token}")
|
||||||
current_session = session_token
|
current_session = session_token
|
||||||
|
|
||||||
ws_url = build_ws_url(session_token, match_id, group_name)
|
ws_url = build_ws_url(session_token, match_id, group_name, operator_login)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(ws_url, ping_interval=None, max_size=2**20) as ws:
|
async with websockets.connect(ws_url, ping_interval=None, max_size=2**20) as ws:
|
||||||
@@ -124,6 +133,7 @@ async def run_agent():
|
|||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
"match_id": match_id,
|
"match_id": match_id,
|
||||||
"group_name": group_name,
|
"group_name": group_name,
|
||||||
|
"operator_name": operator_login,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
pinger = asyncio.create_task(ping_loop(ws))
|
pinger = asyncio.create_task(ping_loop(ws))
|
||||||
@@ -132,8 +142,16 @@ async def run_agent():
|
|||||||
while True:
|
while True:
|
||||||
# следим, не сменился ли проект/session в vMix
|
# следим, не сменился ли проект/session в vMix
|
||||||
latest = read_vmix_dynamic_values()
|
latest = read_vmix_dynamic_values()
|
||||||
if latest["ok"] and latest["session_token"] and latest["session_token"] != current_session:
|
if latest["ok"]:
|
||||||
print("[agent] session changed in vMix, reconnecting")
|
latest_match_id = latest.get("match_id")
|
||||||
|
latest_group_name = latest.get("group_name")
|
||||||
|
latest_operator_login = latest.get("operator_login")
|
||||||
|
if (
|
||||||
|
latest_match_id != match_id
|
||||||
|
or latest_group_name != group_name
|
||||||
|
or latest_operator_login != operator_login
|
||||||
|
):
|
||||||
|
print("[agent] routing meta changed in vMix, reconnecting")
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -150,6 +168,8 @@ async def run_agent():
|
|||||||
"type": "vmix_result",
|
"type": "vmix_result",
|
||||||
"client_id": CLIENT_ID,
|
"client_id": CLIENT_ID,
|
||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
|
"match_id": match_id,
|
||||||
|
"operator_name": operator_login,
|
||||||
"results": results,
|
"results": results,
|
||||||
"sent_at": time.time(),
|
"sent_at": time.time(),
|
||||||
}))
|
}))
|
||||||
|
|||||||
156
app.py
156
app.py
@@ -16,11 +16,8 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import io
|
import io
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import traceback
|
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from db import get_connection
|
|
||||||
|
|
||||||
from repositories.match_formation_repository import (
|
from repositories.match_formation_repository import (
|
||||||
get_match_formations,
|
get_match_formations,
|
||||||
@@ -29,9 +26,6 @@ from repositories.match_formation_repository import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from parsers.parser_game import run_parser_game
|
from parsers.parser_game import run_parser_game
|
||||||
from parsers.parser_players import run_parser_players
|
|
||||||
from parsers.parser_schedule import run_parser_schedule
|
|
||||||
from parsers.parser_standings import run_parser_standings
|
|
||||||
from repositories.match_session_repository import (
|
from repositories.match_session_repository import (
|
||||||
create_match_session,
|
create_match_session,
|
||||||
get_match_session_by_token,
|
get_match_session_by_token,
|
||||||
@@ -91,7 +85,6 @@ from repositories.match_event_repository import (
|
|||||||
from repositories.auth_repository import get_user_by_username
|
from repositories.auth_repository import get_user_by_username
|
||||||
from services.auth_service import (
|
from services.auth_service import (
|
||||||
create_auth_session,
|
create_auth_session,
|
||||||
hash_password,
|
|
||||||
verify_password,
|
verify_password,
|
||||||
get_current_user_from_request,
|
get_current_user_from_request,
|
||||||
build_not_authenticated_response,
|
build_not_authenticated_response,
|
||||||
@@ -128,6 +121,7 @@ class PublishVmixCommandPayload(BaseModel):
|
|||||||
commands: List[str] = Field(default_factory=list)
|
commands: List[str] = Field(default_factory=list)
|
||||||
target_client_id: str | None = None
|
target_client_id: str | None = None
|
||||||
target_group: str | None = None
|
target_group: str | None = None
|
||||||
|
target_login: str | None = None
|
||||||
meta: dict | None = None
|
meta: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -242,6 +236,7 @@ class VmixConnectionManager:
|
|||||||
message: dict,
|
message: dict,
|
||||||
target_group: str | None = None,
|
target_group: str | None = None,
|
||||||
session_token: str | None = None,
|
session_token: str | None = None,
|
||||||
|
target_login: str | None = None,
|
||||||
):
|
):
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
items = list(self.connections.values())
|
items = list(self.connections.values())
|
||||||
@@ -252,6 +247,8 @@ class VmixConnectionManager:
|
|||||||
continue
|
continue
|
||||||
if target_group and conn.get("group_name") != target_group:
|
if target_group and conn.get("group_name") != target_group:
|
||||||
continue
|
continue
|
||||||
|
if target_login and conn.get("operator_name") != target_login:
|
||||||
|
continue
|
||||||
if session_token and conn.get("session_token") != session_token:
|
if session_token and conn.get("session_token") != session_token:
|
||||||
continue
|
continue
|
||||||
matched.append(conn["client_id"])
|
matched.append(conn["client_id"])
|
||||||
@@ -578,13 +575,20 @@ def download_vmix_project_page(request: Request, session_token: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/admin/session/{session_token}/download-vmix")
|
@app.get("/admin/session/{session_token}/download-vmix")
|
||||||
def download_vmix_project(session_token: str):
|
def download_vmix_project(request: Request, session_token: str):
|
||||||
session_row = get_match_session_by_token(session_token)
|
session_row = get_match_session_by_token(session_token)
|
||||||
if not session_row:
|
if not session_row:
|
||||||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||||||
|
|
||||||
|
current_user = getattr(request.state, "current_user", None) or {}
|
||||||
|
operator_login = current_user.get("username") or None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
vmix_bytes = build_vmix_project_bytes(session_token=session_token, match_id=session_row[1])
|
vmix_bytes = build_vmix_project_bytes(
|
||||||
|
session_token=session_token,
|
||||||
|
match_id=session_row[1],
|
||||||
|
operator_login=operator_login,
|
||||||
|
)
|
||||||
filename = build_vmix_filename(session_row)
|
filename = build_vmix_filename(session_row)
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
@@ -732,143 +736,17 @@ def close_session(session_token: str):
|
|||||||
return RedirectResponse(url="/admin/matches", status_code=303)
|
return RedirectResponse(url="/admin/matches", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
def render_admin_db_index(
|
@app.get("/admin/db", response_class=HTMLResponse)
|
||||||
request: Request,
|
def admin_db_index(request: Request):
|
||||||
parser_output: str | None = None,
|
|
||||||
parser_name: str | None = None,
|
|
||||||
parser_success: bool | None = None,
|
|
||||||
account_output: str | None = None,
|
|
||||||
account_success: bool | None = None,
|
|
||||||
):
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
name="admin_db_index.html",
|
name="admin_db_index.html",
|
||||||
request=request,
|
request=request,
|
||||||
context={
|
context={
|
||||||
"current_user": getattr(request.state, "current_user", None),
|
"current_user": getattr(request.state, "current_user", None),
|
||||||
"parser_output": parser_output,
|
|
||||||
"parser_name": parser_name,
|
|
||||||
"parser_success": parser_success,
|
|
||||||
"account_output": account_output,
|
|
||||||
"account_success": account_success,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/db", response_class=HTMLResponse)
|
|
||||||
def admin_db_index(request: Request):
|
|
||||||
return render_admin_db_index(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/db/run-parser", response_class=HTMLResponse)
|
|
||||||
def admin_db_run_parser(request: Request, parser_name: str = Form(...)):
|
|
||||||
current_user = getattr(request.state, "current_user", None)
|
|
||||||
if not current_user or current_user.get("role") != "admin":
|
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
|
||||||
|
|
||||||
parser_map = {
|
|
||||||
"players": ("Игроки", run_parser_players),
|
|
||||||
"schedule": ("Расписание", run_parser_schedule),
|
|
||||||
"standings": ("Турнирка", run_parser_standings),
|
|
||||||
}
|
|
||||||
|
|
||||||
parser_meta = parser_map.get(parser_name)
|
|
||||||
if not parser_meta:
|
|
||||||
return render_admin_db_index(
|
|
||||||
request,
|
|
||||||
parser_output="Неизвестный парсер.",
|
|
||||||
parser_name=parser_name,
|
|
||||||
parser_success=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
title, parser_func = parser_meta
|
|
||||||
buffer = io.StringIO()
|
|
||||||
success = True
|
|
||||||
|
|
||||||
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
|
|
||||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Запуск парсера: {title}")
|
|
||||||
try:
|
|
||||||
parser_func()
|
|
||||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Парсер завершён успешно")
|
|
||||||
except Exception:
|
|
||||||
success = False
|
|
||||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Ошибка при выполнении парсера")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
|
|
||||||
output = buffer.getvalue().strip() or "Парсер не вернул сообщений."
|
|
||||||
return render_admin_db_index(
|
|
||||||
request,
|
|
||||||
parser_output=output,
|
|
||||||
parser_name=title,
|
|
||||||
parser_success=success,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/db/create-account", response_class=HTMLResponse)
|
|
||||||
def admin_db_create_account(
|
|
||||||
request: Request,
|
|
||||||
username: str = Form(...),
|
|
||||||
password: str = Form(...),
|
|
||||||
role: str = Form(default="operator"),
|
|
||||||
):
|
|
||||||
current_user = getattr(request.state, "current_user", None)
|
|
||||||
if not current_user or current_user.get("role") != "admin":
|
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
|
||||||
|
|
||||||
username = username.strip()
|
|
||||||
role = (role or "operator").strip().lower()
|
|
||||||
|
|
||||||
if not username:
|
|
||||||
return render_admin_db_index(request, account_output="Логин обязателен.", account_success=False)
|
|
||||||
|
|
||||||
if not password:
|
|
||||||
return render_admin_db_index(request, account_output="Пароль обязателен.", account_success=False)
|
|
||||||
|
|
||||||
if len(password) < 6:
|
|
||||||
return render_admin_db_index(request, account_output="Пароль должен быть не короче 6 символов.", account_success=False)
|
|
||||||
|
|
||||||
if role not in {"admin", "operator"}:
|
|
||||||
return render_admin_db_index(request, account_output="Роль должна быть admin или operator.", account_success=False)
|
|
||||||
|
|
||||||
password_hash = hash_password(password)
|
|
||||||
conn = get_connection()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO admin_users (username, password_hash, role, is_active)
|
|
||||||
VALUES (%s, %s, %s, TRUE)
|
|
||||||
ON CONFLICT (username) DO NOTHING
|
|
||||||
RETURNING id;
|
|
||||||
""",
|
|
||||||
(username, password_hash, role),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
return render_admin_db_index(
|
|
||||||
request,
|
|
||||||
account_output=f"Пользователь создан: {username} (id={row[0]})\nРоль: {role}",
|
|
||||||
account_success=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_admin_db_index(
|
|
||||||
request,
|
|
||||||
account_output=f"Пользователь '{username}' уже существует.",
|
|
||||||
account_success=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
conn.rollback()
|
|
||||||
return render_admin_db_index(
|
|
||||||
request,
|
|
||||||
account_output="Ошибка при создании аккаунта:\n" + traceback.format_exc(),
|
|
||||||
account_success=False,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@app.get("/admin/db/players", response_class=HTMLResponse)
|
@app.get("/admin/db/players", response_class=HTMLResponse)
|
||||||
def admin_db_players(request: Request, q: str = Query(default="")):
|
def admin_db_players(request: Request, q: str = Query(default="")):
|
||||||
# denied = require_role(request, {"admin"})
|
# denied = require_role(request, {"admin"})
|
||||||
@@ -1700,6 +1578,7 @@ async def ws_vmix_client(
|
|||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"match_id": match_id,
|
"match_id": match_id,
|
||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
|
"operator_name": operator_name,
|
||||||
})
|
})
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -1748,6 +1627,7 @@ async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Requ
|
|||||||
"match_id": payload.match_id,
|
"match_id": payload.match_id,
|
||||||
"session_token": payload.session_token,
|
"session_token": payload.session_token,
|
||||||
"commands": commands,
|
"commands": commands,
|
||||||
|
"target_login": payload.target_login,
|
||||||
"meta": payload.meta or {},
|
"meta": payload.meta or {},
|
||||||
"sent_at": time.time(),
|
"sent_at": time.time(),
|
||||||
}
|
}
|
||||||
@@ -1761,6 +1641,7 @@ async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Requ
|
|||||||
message=message,
|
message=message,
|
||||||
target_group=payload.target_group,
|
target_group=payload.target_group,
|
||||||
session_token=payload.session_token,
|
session_token=payload.session_token,
|
||||||
|
target_login=payload.target_login,
|
||||||
)
|
)
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
@@ -1773,6 +1654,7 @@ async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Requ
|
|||||||
details={
|
details={
|
||||||
"target_client_id": payload.target_client_id,
|
"target_client_id": payload.target_client_id,
|
||||||
"target_group": payload.target_group,
|
"target_group": payload.target_group,
|
||||||
|
"target_login": payload.target_login,
|
||||||
"commands_count": len(commands),
|
"commands_count": len(commands),
|
||||||
"results": results,
|
"results": results,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const REFEREE_ROLE_OPTIONS = [
|
|||||||
|
|
||||||
const VMIX_TARGET_CLIENT_ID = MATCH_DATA.vmixTargetClientId || null;
|
const VMIX_TARGET_CLIENT_ID = MATCH_DATA.vmixTargetClientId || null;
|
||||||
const VMIX_TARGET_GROUP = MATCH_DATA.vmixTargetGroup || null;
|
const VMIX_TARGET_GROUP = MATCH_DATA.vmixTargetGroup || null;
|
||||||
|
const CURRENT_USER_LOGIN = MATCH_DATA.currentUser?.username || null;
|
||||||
|
|
||||||
function vmixCmd(path) {
|
function vmixCmd(path) {
|
||||||
const value = String(path || "").trim();
|
const value = String(path || "").trim();
|
||||||
@@ -1467,9 +1468,9 @@ async function sendDirectVmixCommands(commands) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
match_id: MATCH_ID,
|
match_id: MATCH_ID,
|
||||||
session_token: SESSION_TOKEN,
|
|
||||||
target_client_id: VMIX_TARGET_CLIENT_ID,
|
target_client_id: VMIX_TARGET_CLIENT_ID,
|
||||||
target_group: VMIX_TARGET_GROUP,
|
target_group: VMIX_TARGET_GROUP,
|
||||||
|
target_login: CURRENT_USER_LOGIN,
|
||||||
commands: items
|
commands: items
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1072,6 +1072,8 @@ data-role="{{ p.position or '' }}"
|
|||||||
referees: {{ referees | tojson }},
|
referees: {{ referees | tojson }},
|
||||||
refereePool: {{ referee_pool | tojson }},
|
refereePool: {{ referee_pool | tojson }},
|
||||||
|
|
||||||
|
currentUser: {{ current_user | tojson }},
|
||||||
|
|
||||||
homeSquadPool: [],
|
homeSquadPool: [],
|
||||||
awaySquadPool: [],
|
awaySquadPool: [],
|
||||||
homeCoachPool: [],
|
homeCoachPool: [],
|
||||||
|
|||||||
@@ -133,12 +133,18 @@ def change_vmix_datasource_urls(
|
|||||||
# value2 = match_id
|
# value2 = match_id
|
||||||
get_or_create_dynamic_value(1).text = "" if match_id is None else str(match_id)
|
get_or_create_dynamic_value(1).text = "" if match_id is None else str(match_id)
|
||||||
|
|
||||||
|
# value4 = operator_login
|
||||||
|
get_or_create_dynamic_value(3).text = "" if not operator_login else str(operator_login)
|
||||||
|
|
||||||
return ET.tostring(root, encoding="utf-8", method="xml")
|
return ET.tostring(root, encoding="utf-8", method="xml")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def build_vmix_project_bytes(session_token: str, match_id: str | int | None = None) -> bytes:
|
def build_vmix_project_bytes(
|
||||||
|
session_token: str,
|
||||||
|
match_id: str | int | None = None,
|
||||||
|
operator_login: str | None = None,
|
||||||
|
) -> bytes:
|
||||||
vmix_bio = nasio.load_bio(
|
vmix_bio = nasio.load_bio(
|
||||||
user=SYNO_USERNAME,
|
user=SYNO_USERNAME,
|
||||||
password=SYNO_PASSWORD,
|
password=SYNO_PASSWORD,
|
||||||
@@ -152,6 +158,7 @@ def build_vmix_project_bytes(session_token: str, match_id: str | int | None = No
|
|||||||
FQDN,
|
FQDN,
|
||||||
session_token,
|
session_token,
|
||||||
match_id,
|
match_id,
|
||||||
|
operator_login,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(edited_vmix, str):
|
if isinstance(edited_vmix, str):
|
||||||
|
|||||||
Reference in New Issue
Block a user