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 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);
}
}
}

176
server.py
View File

@ -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('/<path: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
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 (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
"<body>Redirecting to <a href='/login'>/login</a>...</body></html>",
302,
{'Location': '/login'}
)
# Redirect everything else to login
return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
"<body>Redirecting to <a href='/login'>/login</a>...</body></html>",
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 (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
"<body>Auth disabled. Redirecting...</body></html>",
302,
{'Location': '/'}
)
if not DJ_AUTH_ENABLED:
# If auth is disabled, just go to the panel.
session['dj_authed'] = True
return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
"<body>Auth disabled. Redirecting...</body></html>",
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 (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
"<body>Logged in. Redirecting...</body></html>",
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 (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
"<body>Logged in. Redirecting...</body></html>",
302,
{'Location': '/'}
)
error = 'Invalid password'
# Minimal inline login page (no new assets)
return f"""<!doctype html>
# Minimal inline login page (no new assets)
return f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
@ -561,22 +572,22 @@ def dj_login():
@dj_app.route('/logout')
def dj_logout():
session.pop('dj_authed', None)
return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
"<body>Logged out. Redirecting...</body></html>",
302,
{'Location': '/login'}
)
session.pop('dj_authed', None)
return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
"<body>Logged out. Redirecting...</body></html>",
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)

View File

@ -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"""