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(url):
raw = str(url or "").strip()
if not raw:
return ""
return quote(raw, 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 %}
{# 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 %}
{% set role_icon.path = config.role_icons[group_id] %}
{% endif %}
{% endfor %}
{% if role_icon.path %}
{% else %}
{% endif %}
{{ client.get('client_nickname', '') }}
{% if client.get('is_afk') %}
(AFK{% if client.get('afk_text') %}: {{ client.get('afk_text') }}{% endif %})
{% endif %}
{% if client.get('is_streaming') %}
(Streaming{% if client.get('stream_text') %}: {{ client.get('stream_text') }}{% endif %})
{% endif %}
{% endfor %}
{% 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 %}
{% if config.external_links %}
{% endif %}
{{ config.buttons.join }}
{{ config.buttons.download_text }} {{ config.buttons.download_link }}
{% if voice_available %}
Live
-as-
{% 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"])),
)