import json import os import time from pathlib import Path from urllib.parse import quote 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, VOICE_AVAILABLE 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", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".ico", } def safe_url(filename): return quote(str(filename or ""), safe="/") 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"]: try: return json.loads(cache_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): pass base_url = f"https://{cfg['host']}:{cfg['port']}/{cfg['server_id']}" headers = {"x-api-key": cfg["api_key"]} try: 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 [] 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 [], "clients": client_list or [], } try: cache_file.write_text(json.dumps(data), encoding="utf-8") except OSError: pass return data def same_id(left, right): return str(left or 0) == str(right or 0) def sort_ts_channels(channels, pid=0): ordered = [] by_order = {} for channel in channels: if same_id(channel.get("pid", 0), pid): by_order[str(channel.get("channel_order", 0))] = channel current_order = "0" max_iterations = len(channels) + 1 iterations = 0 while current_order in by_order and iterations < max_iterations: channel = by_order[current_order] ordered.append(channel) ordered.extend(sort_ts_channels(channels, channel.get("cid"))) current_order = str(channel.get("cid")) iterations += 1 return ordered def build_context(): start_time = time.perf_counter() ts_data = fetch_teamspeak_data(CONFIG["server"]) api_ping = round((time.perf_counter() - start_time) * 1000) raw_channels = ts_data.get("channels") or [] afk_count = None for channel in raw_channels: original_name = channel.get("channel_name", "") channel["is_spacer"] = "[Cspacer]" in original_name and "AFK" not in original_name # Channels with the keyword "AFK" count all connected clients as AFK regardless of status if "AFK" in original_name: afk_count = channel.get("total_clients", None) channel["channel_name"] = original_name.replace("[Cspacer]", "") raw_clients = ts_data.get("clients") or [] is_online = bool(raw_channels) if afk_count is not None: afk_count = int(afk_count) - 1 else: afk_count = 0 channels = sort_ts_channels(raw_channels, 0) clients_by_channel = {} total_real_clients = 0 if is_online: for client in raw_clients: if int(client.get("client_type") or 0) == 1: continue if client.get("is_afk"): afk_count += 1 clients_by_channel.setdefault(str(client.get("cid")), []).append(client) total_real_clients += 1 return { "config": CONFIG, "api_ping": api_ping, "channels": channels, "clients_by_channel": clients_by_channel, "is_online": is_online, "safe_url": safe_url, "total_real_clients": total_real_clients, "afk_count": afk_count, "voice_available": VOICE_AVAILABLE, "voice_public": VOICE_PUBLIC_CONFIG, } FRAGMENT_TEMPLATE = """ {% if is_online %}
Users Connected {{ total_real_clients }}
{% if afk_count is not none %}
AFK Users {{ afk_count }}
{% endif %}
{% for channel in channels %} {% set c_name = channel.get('channel_name', '') %} {% set cid = channel.get('cid')|string %} {% set channel_clients = clients_by_channel.get(cid, []) %} {% set bg_img = config.channel_banners.get(c_name, config.generic_images.get('background', '')) %} {% set icon_img = config.channel_icons.get(c_name, config.generic_images.get('icon', '')) %} {% if bg_img %} {% set bg_style = "background-image: linear-gradient(90deg, rgba(9, 18, 32, 0.96) 0%, rgba(9, 18, 32, 0.78) 45%, rgba(9, 18, 32, 0.94) 100%), url('" ~ safe_url(bg_img) ~ "');" %} {% else %} {% set bg_style = "" %} {% endif %} {% if not channel.is_spacer %}
{% if icon_img %} {% endif %}

{{ c_name }}

{# Only show client count for non-spacer channels #}
{{ channel_clients|length }} {% if voice_available %} {% endif %}
{# Only show client list for non-spacer channels #}
{% endif %} {% endfor %}
{% else %}
Unable to reach the TeamSpeak server.
{% endif %} """ PAGE_TEMPLATE = """ {{ config.ui.title }}
{% if is_online %}
{% endif %} {% if voice_available %}
[ not logged in ]
{% endif %} {% if config.ui.banner_url %} {% endif %}
{% if voice_available %}{% endif %}
{{ html_fragment|safe }}
{% if config.external_links %} {% endif %}

{{ config.ui.title }}

{{ config.ui.subtitle }}

{% if is_online %}
SERVER ONLINE
{% else %}
SERVER OFFLINE
{% endif %}
{{ config.buttons.join }}

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

{% if voice_available %}

Browser connect

default channel
{% endif %} {% if voice_available %}{% endif %} """ def render_fragment(context): return render_template_string(FRAGMENT_TEMPLATE, **context) @app.route("/") def index(): context = build_context() html_fragment = render_fragment(context) if request.args.get("ajax") == "1": return Response(html_fragment, mimetype="text/html; charset=utf-8") return render_template_string(PAGE_TEMPLATE, html_fragment=html_fragment, **context) @app.route("/") def static_asset(filename): path = (BASE_DIR / filename).resolve() 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) return Response("Not found", status=404) if __name__ == "__main__": # 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"])), )