From df283498eb80f1fd45ff98be00206766fa2b0ade Mon Sep 17 00:00:00 2001 From: ComputerTech Date: Mon, 9 Mar 2026 18:02:47 +0000 Subject: [PATCH] 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 --- config.example.json | 15 +++- script.js | 9 ++- server.py | 176 +++++++++++++++++++++++--------------------- techdj_qt.py | 35 +++++---- 4 files changed, 135 insertions(+), 100 deletions(-) diff --git a/config.example.json b/config.example.json index 15491b5..8654880 100644 --- a/config.example.json +++ b/config.example.json @@ -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 } diff --git a/script.js b/script.js index aa4e966..e41841c 100644 --- a/script.js +++ b/script.js @@ -1840,6 +1840,7 @@ window.addEventListener('DOMContentLoaded', () => { let socket = null; let streamDestination = null; let streamProcessor = null; +let mediaRecorder = null; let isBroadcasting = false; let autoStartStream = false; let listenerAudioContext = null; @@ -2063,8 +2064,12 @@ function updateUIFromMixerStatus(status) { }); // Update crossfader if changed significantly - if (Math.abs(decks.crossfader - status.crossfader) > 1) { - // We'd update the UI slider here + if (status.crossfader !== undefined) { + const cf = document.getElementById('crossfader'); + if (cf && Math.abs(parseInt(cf.value) - status.crossfader) > 1) { + cf.value = status.crossfader; + updateCrossfader(status.crossfader); + } } } diff --git a/server.py b/server.py index 2d3e665..71af96a 100644 --- a/server.py +++ b/server.py @@ -4,6 +4,7 @@ eventlet.monkey_patch() import os import json +import re import subprocess import threading import queue @@ -33,6 +34,16 @@ def _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_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD) @@ -46,7 +57,7 @@ dj_sids = set() # === Optional MP3 fallback stream (server-side transcoding) === _ffmpeg_proc = None _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_lock = threading.Lock() _transcoder_bytes_out = 0 @@ -243,7 +254,9 @@ def _load_settings(): return {} 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 if not os.path.exists(MUSIC_FOLDER): @@ -329,18 +342,16 @@ def setup_shared_routes(app): @app.route('/') 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 if '..' in filename or filename.startswith('/'): from flask import abort 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) if filename.endswith(('.css', '.js', '.html')): 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 # Sanitize filename (keep extension) - import re 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'\s+', ' ', name_without_ext).strip() @@ -466,65 +476,66 @@ def setup_shared_routes(app): '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.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) @dj_app.before_request 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). - """ - if not DJ_AUTH_ENABLED: - return None + This does not affect the listener server (port 5001). + """ + if not DJ_AUTH_ENABLED: + return None - # Allow login/logout endpoints - if request.path in ('/login', '/logout'): - return None + # Allow login/logout endpoints + if request.path in ('/login', '/logout'): + return None - # If already authenticated, allow - if session.get('dj_authed') is True: - return None + # If already authenticated, allow + if session.get('dj_authed') is True: + return None - # Redirect everything else to login - return ( - "" - "Redirecting to /login...", - 302, - {'Location': '/login'} - ) + # Redirect everything else to login + return ( + "" + "Redirecting to /login...", + 302, + {'Location': '/login'} + ) @dj_app.route('/login', methods=['GET', 'POST']) def dj_login(): - if not DJ_AUTH_ENABLED: - # If auth is disabled, just go to the panel. - session['dj_authed'] = True - return ( - "" - "Auth disabled. Redirecting...", - 302, - {'Location': '/'} - ) + if not DJ_AUTH_ENABLED: + # If auth is disabled, just go to the panel. + session['dj_authed'] = True + return ( + "" + "Auth disabled. Redirecting...", + 302, + {'Location': '/'} + ) - error = None - if request.method == 'POST': - pw = (request.form.get('password') or '').strip() - if pw == DJ_PANEL_PASSWORD: - session['dj_authed'] = True - return ( - "" - "Logged in. Redirecting...", - 302, - {'Location': '/'} - ) - error = 'Invalid password' + error = None + if request.method == 'POST': + pw = (request.form.get('password') or '').strip() + if pw == DJ_PANEL_PASSWORD: + session['dj_authed'] = True + return ( + "" + "Logged in. Redirecting...", + 302, + {'Location': '/'} + ) + error = 'Invalid password' - # Minimal inline login page (no new assets) - return f""" + # Minimal inline login page (no new assets) + return f""" @@ -561,22 +572,22 @@ def dj_login(): @dj_app.route('/logout') def dj_logout(): - session.pop('dj_authed', None) - return ( - "" - "Logged out. Redirecting...", - 302, - {'Location': '/login'} - ) + session.pop('dj_authed', None) + return ( + "" + "Logged out. Redirecting...", + 302, + {'Location': '/login'} + ) dj_socketio = SocketIO( dj_app, - cors_allowed_origins="*", + cors_allowed_origins=CONFIG_CORS, async_mode='eventlet', - max_http_buffer_size=1e8, # 100MB buffer + max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024, ping_timeout=10, ping_interval=5, - logger=False, - engineio_logger=False + logger=CONFIG_DEBUG, + engineio_logger=CONFIG_DEBUG ) @dj_socketio.on('connect') @@ -649,9 +660,10 @@ def dj_audio(data): if isinstance(data, (bytes, bytearray)): _feed_transcoder(bytes(data)) -# === LISTENER SERVER (Port 5001) === +# === LISTENER SERVER === 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) # Block write/admin endpoints on the listener server @@ -664,13 +676,13 @@ def _restrict_listener_routes(): abort(403) listener_socketio = SocketIO( listener_app, - cors_allowed_origins="*", + cors_allowed_origins=CONFIG_CORS, async_mode='eventlet', - max_http_buffer_size=1e8, # 100MB buffer + max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024, ping_timeout=10, ping_interval=5, - logger=False, - engineio_logger=False + logger=CONFIG_DEBUG, + engineio_logger=CONFIG_DEBUG ) @listener_socketio.on('connect') @@ -726,19 +738,19 @@ if __name__ == '__main__': print("=" * 50) print("TECHDJ PRO - DUAL PORT ARCHITECTURE") print("=" * 50) - # Ports from environment or defaults - dj_port = int(os.environ.get('DJ_PORT', 5000)) - listen_port = int(os.environ.get('LISTEN_PORT', 5001)) - - print(f"URL: DJ PANEL API: http://0.0.0.0:{dj_port}") - print(f"URL: LISTEN PAGE: http://0.0.0.0:{listen_port}") + print(f"HOST: {CONFIG_HOST}") + print(f"URL: DJ PANEL -> http://{CONFIG_HOST}:{CONFIG_DJ_PORT}") + print(f"URL: LISTENER -> http://{CONFIG_HOST}:{CONFIG_LISTENER_PORT}") + if DJ_AUTH_ENABLED: + print("AUTH: DJ panel password ENABLED") + else: + print("AUTH: DJ panel password DISABLED") + print(f"DEBUG: {CONFIG_DEBUG}") print("=" * 50) - - # Audio engine DISABLED - print(f"READY: Local Radio server ready on ports {dj_port} & {listen_port}") + print(f"READY: Server ready on {CONFIG_HOST}:{CONFIG_DJ_PORT} & {CONFIG_HOST}:{CONFIG_LISTENER_PORT}") # Run both servers using eventlet's spawn eventlet.spawn(_listener_count_sync_loop) eventlet.spawn(_transcoder_watchdog) - eventlet.spawn(dj_socketio.run, dj_app, host='0.0.0.0', port=dj_port, debug=False) - listener_socketio.run(listener_app, host='0.0.0.0', port=listen_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=CONFIG_HOST, port=CONFIG_LISTENER_PORT, debug=False) diff --git a/techdj_qt.py b/techdj_qt.py index 8a227fc..41b8ae2 100644 --- a/techdj_qt.py +++ b/techdj_qt.py @@ -315,25 +315,27 @@ class YTDownloadWorker(QProcess): def handle_finished(self): if self.exitCode() == 0 and self.final_filename: - # Poll for the file to fully appear on disk (replaces the unreliable 0.5s sleep). - # yt-dlp moves the file after FFmpeg post-processing finishes, so the file - # may take a moment to be visible. We wait up to 10 seconds. - deadline = time.time() + 10.0 - while time.time() < deadline: - if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0: - 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}") + # Use a non-blocking timer to poll for the file instead of blocking the GUI thread. + self._poll_deadline = time.time() + 10.0 + self._poll_timer = QTimer() + self._poll_timer.setInterval(100) + self._poll_timer.timeout.connect(self._check_file_ready) + self._poll_timer.start() 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.") else: 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): def __init__(self, settings_data, parent=None): super().__init__(parent) @@ -2053,7 +2055,10 @@ class DJApp(QMainWindow): print("[RECORDING] Stopped") # 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): """Update the recording timer display"""