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:
parent
1f07b87c36
commit
df283498eb
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
176
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('/<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)
|
||||
|
|
|
|||
35
techdj_qt.py
35
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"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue