Add config.json system; fix bugs across server.py, script.js, techdj_qt.py

- Add config.example.json with all options: host, dj_port, listener_port,
  dj_panel_password, secret_key, music_folder, stream_bitrate, max_upload_mb,
  cors_origins, debug (copy to config.json to use)
- server.py: drive host/ports/secrets/CORS/upload limit from config.json;
  fix serve_static to use allowlist only; move re import to top-level;
  fix inconsistent indentation in login/logout/before_request handlers
- script.js: fix undefined decks.crossfader in updateUIFromMixerStatus;
  declare mediaRecorder as a proper let variable
- techdj_qt.py: replace blocking time.sleep poll in YTDownloadWorker with
  non-blocking QTimer; fix fragile or-chained lambda in recording reset
This commit is contained in:
ComputerTech 2026-03-09 18:02:47 +00:00
parent 1f07b87c36
commit df283498eb
4 changed files with 135 additions and 100 deletions

View File

@ -1,3 +1,16 @@
{ {
"dj_panel_password": "" "_comment": "TechDJ Pro - Configuration File. Copy to config.json and edit.",
"host": "0.0.0.0",
"dj_port": 5000,
"listener_port": 5001,
"dj_panel_password": "",
"secret_key": "",
"music_folder": "",
"stream_bitrate": "192k",
"max_upload_mb": 500,
"cors_origins": "*",
"debug": false
} }

View File

@ -1840,6 +1840,7 @@ window.addEventListener('DOMContentLoaded', () => {
let socket = null; let socket = null;
let streamDestination = null; let streamDestination = null;
let streamProcessor = null; let streamProcessor = null;
let mediaRecorder = null;
let isBroadcasting = false; let isBroadcasting = false;
let autoStartStream = false; let autoStartStream = false;
let listenerAudioContext = null; let listenerAudioContext = null;
@ -2063,8 +2064,12 @@ function updateUIFromMixerStatus(status) {
}); });
// Update crossfader if changed significantly // Update crossfader if changed significantly
if (Math.abs(decks.crossfader - status.crossfader) > 1) { if (status.crossfader !== undefined) {
// We'd update the UI slider here const cf = document.getElementById('crossfader');
if (cf && Math.abs(parseInt(cf.value) - status.crossfader) > 1) {
cf.value = status.crossfader;
updateCrossfader(status.crossfader);
}
} }
} }

176
server.py
View File

@ -4,6 +4,7 @@ eventlet.monkey_patch()
import os import os
import json import json
import re
import subprocess import subprocess
import threading import threading
import queue import queue
@ -33,6 +34,16 @@ def _load_config():
CONFIG = _load_config() CONFIG = _load_config()
# --- Config-driven settings (config.json > env vars > defaults) ---
CONFIG_HOST = (CONFIG.get('host') or os.environ.get('HOST') or '0.0.0.0').strip()
CONFIG_DJ_PORT = int(CONFIG.get('dj_port') or os.environ.get('DJ_PORT') or 5000)
CONFIG_LISTENER_PORT = int(CONFIG.get('listener_port') or os.environ.get('LISTEN_PORT') or 5001)
CONFIG_SECRET = (CONFIG.get('secret_key') or '').strip() or 'dj_panel_secret'
CONFIG_CORS = CONFIG.get('cors_origins', '*')
CONFIG_MAX_UPLOAD_MB = int(CONFIG.get('max_upload_mb') or 500)
CONFIG_DEBUG = bool(CONFIG.get('debug', False))
DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip() DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip()
DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD) DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD)
@ -46,7 +57,7 @@ dj_sids = set()
# === Optional MP3 fallback stream (server-side transcoding) === # === Optional MP3 fallback stream (server-side transcoding) ===
_ffmpeg_proc = None _ffmpeg_proc = None
_ffmpeg_in_q = queue.Queue(maxsize=20) # Optimized for low-latency live streaming _ffmpeg_in_q = queue.Queue(maxsize=20) # Optimized for low-latency live streaming
_current_bitrate = "192k" _current_bitrate = (CONFIG.get('stream_bitrate') or '192k').strip()
_mp3_clients = set() # set[queue.Queue] _mp3_clients = set() # set[queue.Queue]
_mp3_lock = threading.Lock() _mp3_lock = threading.Lock()
_transcoder_bytes_out = 0 _transcoder_bytes_out = 0
@ -243,7 +254,9 @@ def _load_settings():
return {} return {}
SETTINGS = _load_settings() SETTINGS = _load_settings()
MUSIC_FOLDER = SETTINGS.get('library', {}).get('music_folder', 'music') # Config.json music_folder overrides settings.json if set
_config_music = (CONFIG.get('music_folder') or '').strip()
MUSIC_FOLDER = _config_music or SETTINGS.get('library', {}).get('music_folder', 'music')
# Ensure music folder exists # Ensure music folder exists
if not os.path.exists(MUSIC_FOLDER): if not os.path.exists(MUSIC_FOLDER):
@ -329,18 +342,16 @@ def setup_shared_routes(app):
@app.route('/<path:filename>') @app.route('/<path:filename>')
def serve_static(filename): def serve_static(filename):
# Block access to sensitive files
blocked = ('.py', '.pyc', '.env', '.json', '.sh', '.bak', '.log', '.pem', '.key')
# Allow specific safe JSON/JS/CSS files
allowed_extensions = ('.css', '.js', '.html', '.htm', '.png', '.jpg', '.jpeg',
'.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.map')
if filename.endswith(blocked) and not filename.endswith(('.css', '.js')):
from flask import abort
abort(403)
# Prevent path traversal # Prevent path traversal
if '..' in filename or filename.startswith('/'): if '..' in filename or filename.startswith('/'):
from flask import abort from flask import abort
abort(403) abort(403)
# Allow only known safe static file extensions
allowed_extensions = ('.css', '.js', '.html', '.htm', '.png', '.jpg', '.jpeg',
'.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.map')
if not filename.endswith(allowed_extensions):
from flask import abort
abort(403)
response = send_from_directory('.', filename) response = send_from_directory('.', filename)
if filename.endswith(('.css', '.js', '.html')): if filename.endswith(('.css', '.js', '.html')):
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
@ -366,7 +377,6 @@ def setup_shared_routes(app):
return jsonify({"success": False, "error": f"Supported formats: {', '.join(allowed_exts)}"}), 400 return jsonify({"success": False, "error": f"Supported formats: {', '.join(allowed_exts)}"}), 400
# Sanitize filename (keep extension) # Sanitize filename (keep extension)
import re
name_without_ext = os.path.splitext(file.filename)[0] name_without_ext = os.path.splitext(file.filename)[0]
name_without_ext = re.sub(r'[^\w\s-]', '', name_without_ext) name_without_ext = re.sub(r'[^\w\s-]', '', name_without_ext)
name_without_ext = re.sub(r'\s+', ' ', name_without_ext).strip() name_without_ext = re.sub(r'\s+', ' ', name_without_ext).strip()
@ -466,65 +476,66 @@ def setup_shared_routes(app):
'last_audio_chunk_ts': _last_audio_chunk_ts, 'last_audio_chunk_ts': _last_audio_chunk_ts,
}) })
# === DJ SERVER (Port 5000) === # === DJ SERVER ===
dj_app = Flask(__name__, static_folder='.', static_url_path='') dj_app = Flask(__name__, static_folder='.', static_url_path='')
dj_app.config['SECRET_KEY'] = 'dj_panel_secret' dj_app.config['SECRET_KEY'] = CONFIG_SECRET
dj_app.config['MAX_CONTENT_LENGTH'] = CONFIG_MAX_UPLOAD_MB * 1024 * 1024
setup_shared_routes(dj_app) setup_shared_routes(dj_app)
@dj_app.before_request @dj_app.before_request
def _protect_dj_panel(): def _protect_dj_panel():
"""Optionally require a password for the DJ panel only (port 5000). """Optionally require a password for the DJ panel only (port 5000).
This does not affect the listener server (port 5001). This does not affect the listener server (port 5001).
""" """
if not DJ_AUTH_ENABLED: if not DJ_AUTH_ENABLED:
return None return None
# Allow login/logout endpoints # Allow login/logout endpoints
if request.path in ('/login', '/logout'): if request.path in ('/login', '/logout'):
return None return None
# If already authenticated, allow # If already authenticated, allow
if session.get('dj_authed') is True: if session.get('dj_authed') is True:
return None return None
# Redirect everything else to login # Redirect everything else to login
return ( return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>" "<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
"<body>Redirecting to <a href='/login'>/login</a>...</body></html>", "<body>Redirecting to <a href='/login'>/login</a>...</body></html>",
302, 302,
{'Location': '/login'} {'Location': '/login'}
) )
@dj_app.route('/login', methods=['GET', 'POST']) @dj_app.route('/login', methods=['GET', 'POST'])
def dj_login(): def dj_login():
if not DJ_AUTH_ENABLED: if not DJ_AUTH_ENABLED:
# If auth is disabled, just go to the panel. # If auth is disabled, just go to the panel.
session['dj_authed'] = True session['dj_authed'] = True
return ( return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>" "<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
"<body>Auth disabled. Redirecting...</body></html>", "<body>Auth disabled. Redirecting...</body></html>",
302, 302,
{'Location': '/'} {'Location': '/'}
) )
error = None error = None
if request.method == 'POST': if request.method == 'POST':
pw = (request.form.get('password') or '').strip() pw = (request.form.get('password') or '').strip()
if pw == DJ_PANEL_PASSWORD: if pw == DJ_PANEL_PASSWORD:
session['dj_authed'] = True session['dj_authed'] = True
return ( return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>" "<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
"<body>Logged in. Redirecting...</body></html>", "<body>Logged in. Redirecting...</body></html>",
302, 302,
{'Location': '/'} {'Location': '/'}
) )
error = 'Invalid password' error = 'Invalid password'
# Minimal inline login page (no new assets) # Minimal inline login page (no new assets)
return f"""<!doctype html> return f"""<!doctype html>
<html lang=\"en\"> <html lang=\"en\">
<head> <head>
<meta charset=\"utf-8\" /> <meta charset=\"utf-8\" />
@ -561,22 +572,22 @@ def dj_login():
@dj_app.route('/logout') @dj_app.route('/logout')
def dj_logout(): def dj_logout():
session.pop('dj_authed', None) session.pop('dj_authed', None)
return ( return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>" "<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
"<body>Logged out. Redirecting...</body></html>", "<body>Logged out. Redirecting...</body></html>",
302, 302,
{'Location': '/login'} {'Location': '/login'}
) )
dj_socketio = SocketIO( dj_socketio = SocketIO(
dj_app, dj_app,
cors_allowed_origins="*", cors_allowed_origins=CONFIG_CORS,
async_mode='eventlet', async_mode='eventlet',
max_http_buffer_size=1e8, # 100MB buffer max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024,
ping_timeout=10, ping_timeout=10,
ping_interval=5, ping_interval=5,
logger=False, logger=CONFIG_DEBUG,
engineio_logger=False engineio_logger=CONFIG_DEBUG
) )
@dj_socketio.on('connect') @dj_socketio.on('connect')
@ -649,9 +660,10 @@ def dj_audio(data):
if isinstance(data, (bytes, bytearray)): if isinstance(data, (bytes, bytearray)):
_feed_transcoder(bytes(data)) _feed_transcoder(bytes(data))
# === LISTENER SERVER (Port 5001) === # === LISTENER SERVER ===
listener_app = Flask(__name__, static_folder='.', static_url_path='') listener_app = Flask(__name__, static_folder='.', static_url_path='')
listener_app.config['SECRET_KEY'] = 'listener_secret' listener_app.config['SECRET_KEY'] = CONFIG_SECRET + '_listener'
listener_app.config['MAX_CONTENT_LENGTH'] = CONFIG_MAX_UPLOAD_MB * 1024 * 1024
setup_shared_routes(listener_app) setup_shared_routes(listener_app)
# Block write/admin endpoints on the listener server # Block write/admin endpoints on the listener server
@ -664,13 +676,13 @@ def _restrict_listener_routes():
abort(403) abort(403)
listener_socketio = SocketIO( listener_socketio = SocketIO(
listener_app, listener_app,
cors_allowed_origins="*", cors_allowed_origins=CONFIG_CORS,
async_mode='eventlet', async_mode='eventlet',
max_http_buffer_size=1e8, # 100MB buffer max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024,
ping_timeout=10, ping_timeout=10,
ping_interval=5, ping_interval=5,
logger=False, logger=CONFIG_DEBUG,
engineio_logger=False engineio_logger=CONFIG_DEBUG
) )
@listener_socketio.on('connect') @listener_socketio.on('connect')
@ -726,19 +738,19 @@ if __name__ == '__main__':
print("=" * 50) print("=" * 50)
print("TECHDJ PRO - DUAL PORT ARCHITECTURE") print("TECHDJ PRO - DUAL PORT ARCHITECTURE")
print("=" * 50) print("=" * 50)
# Ports from environment or defaults print(f"HOST: {CONFIG_HOST}")
dj_port = int(os.environ.get('DJ_PORT', 5000)) print(f"URL: DJ PANEL -> http://{CONFIG_HOST}:{CONFIG_DJ_PORT}")
listen_port = int(os.environ.get('LISTEN_PORT', 5001)) print(f"URL: LISTENER -> http://{CONFIG_HOST}:{CONFIG_LISTENER_PORT}")
if DJ_AUTH_ENABLED:
print(f"URL: DJ PANEL API: http://0.0.0.0:{dj_port}") print("AUTH: DJ panel password ENABLED")
print(f"URL: LISTEN PAGE: http://0.0.0.0:{listen_port}") else:
print("AUTH: DJ panel password DISABLED")
print(f"DEBUG: {CONFIG_DEBUG}")
print("=" * 50) print("=" * 50)
print(f"READY: Server ready on {CONFIG_HOST}:{CONFIG_DJ_PORT} & {CONFIG_HOST}:{CONFIG_LISTENER_PORT}")
# Audio engine DISABLED
print(f"READY: Local Radio server ready on ports {dj_port} & {listen_port}")
# Run both servers using eventlet's spawn # Run both servers using eventlet's spawn
eventlet.spawn(_listener_count_sync_loop) eventlet.spawn(_listener_count_sync_loop)
eventlet.spawn(_transcoder_watchdog) eventlet.spawn(_transcoder_watchdog)
eventlet.spawn(dj_socketio.run, dj_app, host='0.0.0.0', port=dj_port, debug=False) eventlet.spawn(dj_socketio.run, dj_app, host=CONFIG_HOST, port=CONFIG_DJ_PORT, debug=False)
listener_socketio.run(listener_app, host='0.0.0.0', port=listen_port, debug=False) listener_socketio.run(listener_app, host=CONFIG_HOST, port=CONFIG_LISTENER_PORT, debug=False)

View File

@ -315,25 +315,27 @@ class YTDownloadWorker(QProcess):
def handle_finished(self): def handle_finished(self):
if self.exitCode() == 0 and self.final_filename: if self.exitCode() == 0 and self.final_filename:
# Poll for the file to fully appear on disk (replaces the unreliable 0.5s sleep). # Use a non-blocking timer to poll for the file instead of blocking the GUI thread.
# yt-dlp moves the file after FFmpeg post-processing finishes, so the file self._poll_deadline = time.time() + 10.0
# may take a moment to be visible. We wait up to 10 seconds. self._poll_timer = QTimer()
deadline = time.time() + 10.0 self._poll_timer.setInterval(100)
while time.time() < deadline: self._poll_timer.timeout.connect(self._check_file_ready)
if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0: self._poll_timer.start()
break
time.sleep(0.1)
if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0:
print(f"[DEBUG] Download complete: {self.final_filename} ({os.path.getsize(self.final_filename)} bytes)")
self.download_finished.emit(self.final_filename)
else:
self.error_occurred.emit(f"Download finished but file missing or empty:\n{self.final_filename}")
elif self.exitCode() == 0 and not self.final_filename: elif self.exitCode() == 0 and not self.final_filename:
self.error_occurred.emit("Download finished but could not determine output filename.\nCheck the download folder manually.") self.error_occurred.emit("Download finished but could not determine output filename.\nCheck the download folder manually.")
else: else:
self.error_occurred.emit(f"Download process failed.\n{self.error_log}") self.error_occurred.emit(f"Download process failed.\n{self.error_log}")
def _check_file_ready(self):
"""Non-blocking poll: check if the downloaded file has appeared on disk."""
if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0:
self._poll_timer.stop()
print(f"[DEBUG] Download complete: {self.final_filename} ({os.path.getsize(self.final_filename)} bytes)")
self.download_finished.emit(self.final_filename)
elif time.time() > self._poll_deadline:
self._poll_timer.stop()
self.error_occurred.emit(f"Download finished but file missing or empty:\n{self.final_filename}")
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
def __init__(self, settings_data, parent=None): def __init__(self, settings_data, parent=None):
super().__init__(parent) super().__init__(parent)
@ -2053,7 +2055,10 @@ class DJApp(QMainWindow):
print("[RECORDING] Stopped") print("[RECORDING] Stopped")
# Reset status after 3 seconds # Reset status after 3 seconds
QTimer.singleShot(3000, lambda: self.recording_status_label.setText("Ready to record") or self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;")) def _reset_rec_status():
self.recording_status_label.setText("Ready to record")
self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;")
QTimer.singleShot(3000, _reset_rec_status)
def update_recording_time(self): def update_recording_time(self):
"""Update the recording timer display""" """Update the recording timer display"""