diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
| commit | 96bfe2e1505b4ae250f2d5201f9541ceb01cec84 (patch) | |
| tree | b2164a76dc3aa5b69e160d1135d3385bcbe6108d /app.py | |
| parent | 150858067059ba615c548f826bb343589993b4bb (diff) | |
ts web slopify code
Diffstat (limited to 'app.py')
| -rw-r--r-- | app.py | 283 |
1 files changed, 217 insertions, 66 deletions
@@ -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() - - client_list = clients_json.get("body") or [] + 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": []} - afk_threshold = cfg.get("afk_status_max_clients") - fetch_afk_status = afk_threshold is None or len(client_list) <= afk_threshold + client_list = clients_json.get("body") or [] - 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 %} - <article class="channel"> + <article class="channel" data-cid="{{ cid }}"> <header class="channel-header" style="{{ bg_style }}"> <div class="channel-title-group"> {% if icon_img %} @@ -204,15 +264,20 @@ FRAGMENT_TEMPLATE = """ <h2>{{ c_name }}</h2> </div> {# Only show client count for non-spacer channels #} - <span class="channel-meta"> - {{ channel_clients|length }} - </span> + <div class="channel-actions"> + <span class="channel-meta"> + {{ channel_clients|length }} + </span> + {% if voice_available %} + <button type="button" class="channel-join" data-cid="{{ cid }}" data-name="{{ c_name }}" aria-label="Join {{ c_name }} from the browser">join</button> + {% endif %} + </div> </header> {# Only show client list for non-spacer channels #} <ul class="client-list"> {% for client in channel_clients %} - <li class="client"> + <li class="client" data-clid="{{ client.get('clid', '') }}"> {% 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 = """ <main class="app-container"> {% if is_online %}<div class="progress-bar" id="refresh-bar"></div>{% endif %} + {% if voice_available %} + <section class="voice-topbar" id="voice-topbar" aria-label="Browser voice setup"> + <div class="voice-topbar-bar"> + <span class="voice-topbar-identity" id="voice-topbar-identity">[ not logged in ]</span> + <button type="button" class="voice-login-btn" id="voice-login-btn">browser connect</button> + </div> + <p class="voice-topbar-hint" id="voice-message" role="status" aria-live="polite" hidden></p> + </section> + {% endif %} + {% if config.ui.banner_url %} <img src="{{ safe_url(config.ui.banner_url) }}" alt="Header banner" class="banner"> {% endif %} <div class="content-wrapper"> + {% if voice_available %}<p class="voice-notice" id="voice-notice" role="status" aria-live="polite" hidden></p>{% endif %} + <section id="ajax-container" aria-live="polite"> {{ html_fragment|safe }} </section> @@ -300,14 +377,93 @@ PAGE_TEMPLATE = """ <a href="ts3server://{{ config.server.ts_domain }}" class="btn-primary">{{ config.buttons.join }}</a> <p class="help-text">{{ config.buttons.download_text }} <a href="{{ config.buttons.download_url }}" target="_blank" rel="noopener">{{ config.buttons.download_link }}</a></p> - <footer class="credits"> {{ config.footer.text }} <a href="{{ config.footer.author_url }}" target="_blank" rel="noopener">{{ config.footer.author }}</a> — Refresh scan: <span id="timer-text">{{ config.server.cache_time }}</span>s </footer> </div> </main> +{% if voice_available %} +<dialog class="voice-dialog" id="voice-dialog" aria-labelledby="voice-dialog-title"> + <form class="voice-dialog-form" id="voice-join-form" autocomplete="on"> + <header class="voice-dialog-header"> + <h2 id="voice-dialog-title">Browser connect</h2> + <span class="voice-dialog-target" id="voice-dialog-target">default channel</span> + </header> + <div class="voice-dialog-body"> + <label class="voice-field"> + <span class="voice-field-tag">[ nickname ]</span> + <input id="voice-nickname" name="nickname" minlength="3" maxlength="30" required autocomplete="nickname" spellcheck="false" placeholder="enter name..."> + </label> + <label class="voice-field"> + <span class="voice-field-tag">[ server password ]</span> + <input id="voice-password" name="password" type="password" required autocomplete="current-password" placeholder="enter password..."> + </label> + <p class="voice-dialog-message" id="voice-dialog-message" role="status" aria-live="polite" hidden></p> + </div> + <footer class="voice-dialog-actions"> + <button type="button" class="btn-voice" id="voice-cancel">cancel</button> + <button type="submit" class="btn-primary voice-submit" id="voice-submit">connect</button> + </footer> + </form> +</dialog> + +<section class="voice-bar" id="voice-bar" aria-label="Voice controls" hidden> + <div class="voice-meter" aria-hidden="true"> + <div class="voice-meter-fill" id="voice-meter"></div> + <div class="voice-meter-threshold" id="voice-threshold"></div> + </div> + <div class="voice-settings" id="voice-settings" hidden> + <div class="voice-setting"> + <span class="stat-label">Input mode</span> + <button type="button" class="btn-voice" id="voice-mode">voice activity</button> + </div> + <label class="voice-setting"> + <span class="stat-label">Mic sensitivity</span> + <input type="range" id="voice-sensitivity" min="0" max="100" value="45"> + </label> + <label class="voice-setting"> + <span class="stat-label">Volume</span> + <input type="range" id="voice-volume" min="0" max="150" value="100"> + </label> + </div> + <p class="voice-bar-message" id="voice-bar-message" role="status" aria-live="polite" hidden></p> + <div class="voice-bar-main"> + <div class="voice-bar-info"> + <span class="voice-live-badge"><span class="pulse-dot"></span>Live</span> + <span class="voice-where"><span id="voice-channel">-</span><span class="voice-as">as</span><span id="voice-self">-</span></span> + </div> + <div class="voice-buttons"> + <button type="button" class="btn-voice btn-voice-talk" id="voice-ptt" hidden>talk (V)</button> + <button type="button" class="btn-voice" id="voice-mute" aria-pressed="false">mute</button> + <button type="button" class="btn-voice" id="voice-deafen" aria-pressed="false">deafen</button> + <button type="button" class="btn-voice" id="voice-settings-toggle" aria-expanded="false" aria-controls="voice-settings">settings</button> + <button type="button" class="btn-voice btn-voice-danger" id="voice-leave">leave</button> + </div> + </div> +</section> +<script id="voice-config" type="application/json">{{ voice_public|tojson }}</script> +{% endif %} + <script> + // Reloads the server-rendered channel list. Skipped while the web voice + // client renders it live; voice.js calls it again after leaving. + window.refreshDashboard = function () { + const container = document.getElementById('ajax-container'); + if (!container || document.body.classList.contains('voice-live')) return Promise.resolve(); + container.style.opacity = '0.5'; + return fetch(window.location.href.split('?')[0] + '?ajax=1', { cache: 'no-store', headers: {'X-Requested-With': 'XMLHttpRequest'} }) + .then(r => { + if (!r.ok) throw new Error("HTTP error " + r.status); + return r.text(); + }) + .then(html => { + if (!document.body.classList.contains('voice-live')) container.innerHTML = html; + }) + .catch(err => console.error("Refresh failed:", err)) + .finally(() => { container.style.opacity = '1'; }); + }; + {% if is_online %} document.addEventListener("DOMContentLoaded", () => { let timeLeft = {{ config.server.cache_time|int }}; @@ -315,7 +471,6 @@ PAGE_TEMPLATE = """ const progressBar = document.getElementById('refresh-bar'); const timerText = document.getElementById('timer-text'); - const container = document.getElementById('ajax-container'); function resetProgressBar() { progressBar.style.transition = 'none'; @@ -328,36 +483,26 @@ PAGE_TEMPLATE = """ resetProgressBar(); setInterval(() => { + if (document.body.classList.contains('voice-live')) { + timerText.innerText = 'live'; + timeLeft = totalTime; + return; + } timeLeft--; timerText.innerText = timeLeft; if (timeLeft <= 0) { - container.style.opacity = '0.5'; - fetch(window.location.href.split('?')[0] + '?ajax=1', { cache: 'no-store', headers: {'X-Requested-With': 'XMLHttpRequest'} }) - .then(r => { - if (!r.ok) throw new Error("HTTP error " + r.status); - return r.text(); - }) - .then(html => { - container.innerHTML = html; - container.style.opacity = '1'; - - timeLeft = totalTime; - timerText.innerText = timeLeft; - - resetProgressBar(); - }) - .catch(err => { - console.error("Refresh failed:", err); - container.style.opacity = '1'; - timeLeft = totalTime; - resetProgressBar(); - }); + timeLeft = totalTime; + window.refreshDashboard().finally(() => { + timerText.innerText = timeLeft; + resetProgressBar(); + }); } }, 1000); }); {% endif %} </script> +{% if voice_available %}<script src="/static/voice.js" defer></script>{% endif %} </body> </html> """ @@ -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"])), + ) |
