From 96bfe2e1505b4ae250f2d5201f9541ceb01cec84 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Thu, 29 Oct 2026 19:58:05 -0700 Subject: ts web slopify code --- app.py | 283 ++++++++++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 217 insertions(+), 66 deletions(-) (limited to 'app.py') diff --git a/app.py b/app.py index 19c8ccf..2b10dcd 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ import json +import os import time from pathlib import Path from urllib.parse import quote @@ -7,11 +8,24 @@ import requests from flask import Flask, Response, render_template_string, request, send_from_directory from flask_cors import CORS -from config import BASE_DIR, CONFIG +from config import BASE_DIR, CONFIG, VOICE_AVAILABLE -app = Flask(__name__) +app = Flask(__name__, static_folder=str(BASE_DIR / "static"), static_url_path="/static") CORS(app) +# Directories that must never be served by the catch-all asset route. +PRIVATE_DIRS = {"gateway", "reference", "static", ".git", ".venv", "__pycache__"} + +# Everything the browser voice client needs; never include secrets here. +VOICE_PUBLIC_CONFIG = { + "enabled": VOICE_AVAILABLE, + "wsPath": "/ws/voice", + "channelBanners": CONFIG["channel_banners"], + "channelIcons": CONFIG["channel_icons"], + "genericImages": CONFIG["generic_images"], + "roleIcons": CONFIG["role_icons"], +} + ALLOWED_STATIC_EXTENSIONS = { ".css", ".png", @@ -30,6 +44,72 @@ def safe_url(filename): +CLIENT_DETAIL_FIELDS = ("afk_text", "is_afk", "is_streaming", "stream_text") +# After a failed per-user clientinfo call, skip per-user calls for this long so a +# misbehaving server is not hammered on every refresh. +CLIENT_DETAILS_RETRY_SECONDS = 300 +_client_details_paused_until = 0.0 + + +class WebQueryError(Exception): + """The WebQuery answered, but with an error (HTTP or TeamSpeak status).""" + + def __init__(self, message, code=None): + super().__init__(message) + self.code = code + + +# TeamSpeak "invalid clientID": the user left between clientlist and clientinfo. +TS_ERROR_INVALID_CLIENT_ID = "512" + + +def webquery_get(url, headers): + res = requests.get(url, headers=headers, timeout=2) + res.raise_for_status() + payload = res.json() + status = payload.get("status") if isinstance(payload, dict) else None + if isinstance(status, dict) and str(status.get("code", 0)) != "0": + code = str(status.get("code")) + raise WebQueryError(f"{code}: {status.get('message', '')}", code=code) + return payload + + +def fetch_client_details(base_url, headers, client_list): + """Add AFK/streaming state per user via clientinfo. + + All or nothing: if any call fails, every user's details are dropped and the + plain channel/user list is used instead. Returns True on success. + """ + global _client_details_paused_until + try: + for client in client_list: + clid = client.get("clid") + if not clid or client.get("client_nickname") == "serveradmin": + continue + body = None + try: + body = webquery_get(f"{base_url}/clientinfo?clid={clid}", headers).get("body") + except WebQueryError as e: + if e.code != TS_ERROR_INVALID_CLIENT_ID: + raise + client_info = body[0] if isinstance(body, list) and body else {} + client["afk_text"] = client_info.get("client_away_message", "") + client["is_afk"] = client_info.get("client_away") == "1" + client["is_streaming"] = client_info.get("client_is_streaming") == "1" + client["stream_text"] = client_info.get("client_stream_text", "") + return True + except (requests.RequestException, ValueError, WebQueryError) as e: + print( + f"clientinfo failed ({e}); showing users without AFK/streaming details " + f"for the next {CLIENT_DETAILS_RETRY_SECONDS}s" + ) + _client_details_paused_until = time.time() + CLIENT_DETAILS_RETRY_SECONDS + for client in client_list: + for field in CLIENT_DETAIL_FIELDS: + client.pop(field, None) + return False + + def fetch_teamspeak_data(cfg): cache_file = Path(cfg["cache_file"]) if cache_file.exists() and (time.time() - cache_file.stat().st_mtime) < cfg["cache_time"]: @@ -42,43 +122,21 @@ def fetch_teamspeak_data(cfg): headers = {"x-api-key": cfg["api_key"]} try: - channels_res = requests.get(f"{base_url}/channellist", headers=headers, timeout=2) - clients_res = requests.get(f"{base_url}/clientlist?-groups", headers=headers, timeout=2) - channels_json = channels_res.json() - clients_json = clients_res.json() + channels_json = webquery_get(f"{base_url}/channellist", headers) + clients_json = webquery_get(f"{base_url}/clientlist?-groups", headers) + except (requests.RequestException, ValueError, WebQueryError) as e: + print(f"Failed to fetch channel/client list: {e}") + return {"channels": [], "clients": []} - client_list = clients_json.get("body") or [] + client_list = clients_json.get("body") or [] - afk_threshold = cfg.get("afk_status_max_clients") - fetch_afk_status = afk_threshold is None or len(client_list) <= afk_threshold - - for client in client_list: - clid = client.get("clid") - nickname = client.get("client_nickname") - if not fetch_afk_status: - continue - if clid and nickname != "serveradmin": - client_info_res = requests.get( - f"{base_url}/clientinfo?clid={clid}", - headers=headers, - timeout=2, - ) - body = client_info_res.json().get("body") - if isinstance(body, list): - client_info = body[0] if body else {} - else: - client_info = {} - client["afk_text"] = client_info.get("client_away_message", "") - client["is_afk"] = client_info.get("client_away") == "1" - client["is_streaming"] = client_info.get("client_is_streaming") == "1" - client["stream_text"] = client_info.get("client_stream_text", "") - - except requests.RequestException as e: - print(f"RequestException: failed to fetch client info: {e}") - return {"channels": [], "clients": []} - except ValueError as e: - print(f"ValueError: failed to parse client info: {e}") - return {"channels": [], "clients": []} + afk_threshold = cfg.get("afk_status_max_clients") + fetch_afk_status = ( + (afk_threshold is None or len(client_list) <= afk_threshold) + and time.time() >= _client_details_paused_until + ) + if fetch_afk_status: + fetch_client_details(base_url, headers, client_list) data = { "channels": channels_json.get("body") or [], @@ -163,6 +221,8 @@ def build_context(): "safe_url": safe_url, "total_real_clients": total_real_clients, "afk_count": afk_count, + "voice_available": VOICE_AVAILABLE, + "voice_public": VOICE_PUBLIC_CONFIG, } @@ -195,7 +255,7 @@ FRAGMENT_TEMPLATE = """ {% endif %} {% if not channel.is_spacer %} -
+
{% if icon_img %} @@ -204,15 +264,20 @@ FRAGMENT_TEMPLATE = """

{{ c_name }}

{# Only show client count for non-spacer channels #} - - {{ channel_clients|length }} - +
+ + {{ channel_clients|length }} + + {% if voice_available %} + + {% endif %} +
{# Only show client list for non-spacer channels #}
    {% for client in channel_clients %} -
  • +
  • {% set role_icon = namespace(path='') %} {% for group_id in (client.get('client_servergroups', '')|string).split(',') %} {% if not role_icon.path and group_id in config.role_icons %} @@ -270,11 +335,23 @@ PAGE_TEMPLATE = """
    {% if is_online %}
    {% endif %} + {% if voice_available %} +
    +
    + [ not logged in ] + +
    + +
    + {% endif %} + {% if config.ui.banner_url %} {% endif %}
    + {% if voice_available %}{% endif %} +
    {{ html_fragment|safe }}
    @@ -300,14 +377,93 @@ PAGE_TEMPLATE = """ {{ config.buttons.join }}

    {{ config.buttons.download_text }} {{ config.buttons.download_link }}

    -
    +{% if voice_available %} + +
    +
    +

    Browser connect

    + default channel +
    +
    + + + +
    +
    + + +
    +
    +
    + + + +{% endif %} + +{% if voice_available %}{% endif %} """ @@ -384,6 +529,7 @@ def static_asset(filename): if ( path.is_file() and BASE_DIR in path.parents + and path.relative_to(BASE_DIR).parts[0] not in PRIVATE_DIRS and path.suffix.lower() in ALLOWED_STATIC_EXTENSIONS ): return send_from_directory(BASE_DIR, filename) @@ -391,4 +537,9 @@ def static_asset(filename): if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) + # run.py sets DASHBOARD_HOST/DASHBOARD_LISTEN_PORT (internal port when the + # voice gateway fronts the app). Run directly, the dashboard serves alone. + app.run( + host=os.environ.get("DASHBOARD_HOST", CONFIG["web"]["host"]), + port=int(os.environ.get("DASHBOARD_LISTEN_PORT", CONFIG["web"]["port"])), + ) -- cgit v1.2.3