Final bug sweep: fix event param, remove dead code, dedupe CSS; add .gitignore recordings/

This commit is contained in:
ComputerTech 2026-03-05 14:28:17 +00:00
parent 12c01faa83
commit b5ea9e8d01
12 changed files with 6215 additions and 3168 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
music/ music/
recordings/
# Python # Python
__pycache__/ __pycache__/

View File

@ -41,23 +41,23 @@ def main():
# Check Chrome # Check Chrome
chrome_mem, chrome_procs = get_process_memory('chrome') chrome_mem, chrome_procs = get_process_memory('chrome')
if chrome_mem > 0: if chrome_mem > 0:
print(f"🌐 Chrome (Web Panel):") print(f"[CHROME] Chrome (Web Panel):")
print(f" Total Memory: {chrome_mem:.1f} MB") print(f" Total Memory: {chrome_mem:.1f} MB")
print(f" Processes: {chrome_procs}") print(f" Processes: {chrome_procs}")
print() print()
else: else:
print("🌐 Chrome: Not running") print("[CHROME] Chrome: Not running")
print() print()
# Check PyQt5 # Check PyQt6
qt_mem, qt_procs = get_process_memory('techdj_qt') qt_mem, qt_procs = get_process_memory('techdj_qt')
if qt_mem > 0: if qt_mem > 0:
print(f"💻 PyQt5 Native App:") print(f"[PYQT6] PyQt6 Native App:")
print(f" Total Memory: {qt_mem:.1f} MB") print(f" Total Memory: {qt_mem:.1f} MB")
print(f" Processes: {qt_procs}") print(f" Processes: {qt_procs}")
print() print()
else: else:
print("💻 PyQt5 Native App: Not running") print("[PYQT6] PyQt6 Native App: Not running")
print() print()
# Comparison # Comparison
@ -66,25 +66,25 @@ def main():
percent = (savings / chrome_mem) * 100 percent = (savings / chrome_mem) * 100
print("=" * 60) print("=" * 60)
print("📊 Comparison:") print("[STATS] Comparison:")
print(f" Memory Saved: {savings:.1f} MB ({percent:.1f}%)") print(f" Memory Saved: {savings:.1f} MB ({percent:.1f}%)")
print() print()
# Visual bar chart # Visual bar chart
max_mem = max(chrome_mem, qt_mem) max_mem = max(chrome_mem, qt_mem)
chrome_bar = '' * int((chrome_mem / max_mem) * 40) chrome_bar = '#' * int((chrome_mem / max_mem) * 40)
qt_bar = '' * int((qt_mem / max_mem) * 40) qt_bar = '#' * int((qt_mem / max_mem) * 40)
print(" Chrome: " + chrome_bar + f" {chrome_mem:.0f}MB") print(" Chrome: " + chrome_bar + f" {chrome_mem:.0f}MB")
print(" PyQt5: " + qt_bar + f" {qt_mem:.0f}MB") print(" PyQt6: " + qt_bar + f" {qt_mem:.0f}MB")
print() print()
if percent > 50: if percent > 50:
print(f" ✅ PyQt5 uses {percent:.0f}% less memory!") print(f" [OK] PyQt6 uses {percent:.0f}% less memory!")
elif percent > 25: elif percent > 25:
print(f" ✅ PyQt5 uses {percent:.0f}% less memory") print(f" [OK] PyQt6 uses {percent:.0f}% less memory")
else: else:
print(f" PyQt5 uses {percent:.0f}% less memory") print(f" PyQt6 uses {percent:.0f}% less memory")
print("=" * 60) print("=" * 60)
print() print()

BIN
dj_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

View File

@ -29,6 +29,15 @@
USE PORTRAIT MODE USE PORTRAIT MODE
</button> </button>
</div> </div>
<!-- Mobile Top Bar -->
<div class="mobile-top-bar">
<div class="status-indicator">
<span class="status-dot"></span>
<span id="system-status">NEON_v2</span>
</div>
<div class="app-logo">TECHDJ PRO</div>
<div class="clock-display" id="clock-display">00:00</div>
</div>
<!-- MAIN APP CONTAINER --> <!-- MAIN APP CONTAINER -->
<div class="app-container"> <div class="app-container">
@ -36,31 +45,33 @@
<!-- Mobile Tabs --> <!-- Mobile Tabs -->
<nav class="mobile-tabs"> <nav class="mobile-tabs">
<button class="tab-btn active" onclick="switchTab('library')"> <button class="tab-btn active" onclick="switchTab('library')">
<span class="tab-icon"></span> <span class="tab-icon">#</span>
<span>LIBRARY</span> <span>LIB</span>
</button> </button>
<button class="tab-btn" onclick="switchTab('deck-A')"> <button class="tab-btn" onclick="switchTab('deck-A')">
<span class="tab-icon"></span> <span class="tab-icon">A</span>
<span>DECK A</span> <span>DECK A</span>
</button> </button>
<button class="tab-btn" onclick="switchTab('deck-B')"> <button class="tab-btn" onclick="switchTab('deck-B')">
<span class="tab-icon"></span> <span class="tab-icon">B</span>
<span>DECK B</span> <span>DECK B</span>
</button> </button>
<button class="tab-btn" onclick="switchQueueTab()">
<span class="tab-icon">Q</span>
<span id="queue-tab-label">QUEUE A</span>
</button>
<button class="tab-btn fullscreen-btn" onclick="toggleFullScreen()" id="fullscreen-toggle"> <button class="tab-btn fullscreen-btn" onclick="toggleFullScreen()" id="fullscreen-toggle">
<span class="tab-icon"></span> <span class="tab-icon">[]</span>
<span>FULL</span> <span>FULL</span>
</button> </button>
</nav> </nav>
<!-- 1. LEFT: LIBRARY --> <!-- 1. LEFT: LIBRARY -->
<section class="library-section"> <section class="library-section">
<div class="lib-mode-toggle mobile-only">
<button class="mode-btn active" id="btn-server-lib" onclick="setLibraryMode('server')">SERVER</button>
<button class="mode-btn" id="btn-local-lib" onclick="setLibraryMode('local')">LOCAL</button>
</div>
<div class="lib-header"> <div class="lib-header">
<input type="text" id="lib-search" placeholder="FILTER LIBRARY..." onkeyup="filterLibrary()"> <input type="text" id="lib-search" placeholder="FILTER LIBRARY..." onkeyup="filterLibrary()">
<button class="folder-btn" onclick="openFolderPicker()" title="Choose Folder">OPEN</button>
<button class="refresh-btn" onclick="refreshLibrary()" title="Refresh Library">REFRESH</button> <button class="refresh-btn" onclick="refreshLibrary()" title="Refresh Library">REFRESH</button>
</div> </div>
@ -194,7 +205,7 @@
<button class="big-btn repeat-btn" id="repeat-btn-A" onclick="toggleRepeat('A')" <button class="big-btn repeat-btn" id="repeat-btn-A" onclick="toggleRepeat('A')"
title="Loop Track">LOOP</button> title="Loop Track">LOOP</button>
<button class="big-btn sync-btn" onclick="syncDecks('A')">SYNC</button> <button class="big-btn sync-btn" onclick="syncDecks('A')">SYNC</button>
<button class="big-btn reset-btn" onclick="resetDeck('A')" <button class="big-btn reset-btn mob-hide" onclick="resetDeck('A')"
title="Reset all settings to default">RESET</button> title="Reset all settings to default">RESET</button>
</div> </div>
</div> </div>
@ -314,14 +325,13 @@
</div> </div>
</div> </div>
<div class="transport"> <div class="transport">
<button class="big-btn play-btn" onclick="playDeck('B')">PLAY</button> <button class="big-btn play-btn" onclick="playDeck('B')">PLAY</button>
<button class="big-btn pause-btn" onclick="pauseDeck('B')">PAUSE</button> <button class="big-btn pause-btn" onclick="pauseDeck('B')">PAUSE</button>
<button class="big-btn repeat-btn" id="repeat-btn-B" onclick="toggleRepeat('B')" <button class="big-btn repeat-btn" id="repeat-btn-B" onclick="toggleRepeat('B')"
title="Loop Track">LOOP</button> title="Loop Track">LOOP</button>
<button class="big-btn sync-btn" onclick="syncDecks('B')">SYNC</button> <button class="big-btn sync-btn" onclick="syncDecks('B')">SYNC</button>
<button class="big-btn reset-btn" onclick="resetDeck('B')" <button class="big-btn reset-btn mob-hide" onclick="resetDeck('B')"
title="Reset all settings to default">RESET</button> title="Reset all settings to default">RESET</button>
</div> </div>
</div> </div>
@ -330,7 +340,8 @@
<section class="queue-section" id="queue-A"> <section class="queue-section" id="queue-A">
<div class="queue-page-header"> <div class="queue-page-header">
<h2 class="queue-page-title">QUEUE A</h2> <h2 class="queue-page-title">QUEUE A</h2>
<button class="queue-clear-btn" onclick="clearQueue('A')" title="Clear queue">Clear All</button> <button class="queue-clear-btn" onclick="clearQueue('A')" title="Clear queue">Clear
All</button>
</div> </div>
<div class="queue-list" id="queue-list-A"> <div class="queue-list" id="queue-list-A">
<div class="queue-empty">Drop tracks here or click "Queue to A" in library</div> <div class="queue-empty">Drop tracks here or click "Queue to A" in library</div>
@ -340,7 +351,8 @@
<section class="queue-section" id="queue-B"> <section class="queue-section" id="queue-B">
<div class="queue-page-header"> <div class="queue-page-header">
<h2 class="queue-page-title">QUEUE B</h2> <h2 class="queue-page-title">QUEUE B</h2>
<button class="queue-clear-btn" onclick="clearQueue('B')" title="Clear queue">Clear All</button> <button class="queue-clear-btn" onclick="clearQueue('B')" title="Clear queue">Clear
All</button>
</div> </div>
<div class="queue-list" id="queue-list-B"> <div class="queue-list" id="queue-list-B">
<div class="queue-empty">Drop tracks here or click "Queue to B" in library</div> <div class="queue-empty">Drop tracks here or click "Queue to B" in library</div>
@ -441,6 +453,7 @@
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script src="script.js"></script> <script src="script.js"></script>
<!-- Settings Panel --> <!-- Settings Panel -->
<div class="settings-panel" id="settings-panel"> <div class="settings-panel" id="settings-panel">
<div class="settings-header"> <div class="settings-header">
@ -477,14 +490,27 @@
</div> </div>
</div> </div>
</div> </div>
<button class="keyboard-btn" onclick="openKeyboardSettings()" title="Keyboard Shortcuts (H)">KB</button>
<button class="streaming-btn" onclick="toggleStreamingPanel()" title="Live Streaming">STREAM</button> <!-- Tools FAB for Mobile -->
<button class="upload-btn" onclick="document.getElementById('file-upload').click()" <div class="fab-container">
title="Upload MP3">UPLOAD</button> <button class="fab-main" onclick="toggleFabMenu(event)">SET</button>
<input type="file" id="file-upload" accept="audio/mp3,audio/mpeg" multiple style="display:none" <div class="fab-menu" id="fab-menu">
<button class="fab-item" onclick="toggleSettings(); toggleFabMenu()">SET</button>
<button class="fab-item"
onclick="document.getElementById('file-upload').click(); toggleFabMenu()">UP</button>
<button class="fab-item" onclick="toggleStreamingPanel(); toggleFabMenu()">LIVE</button>
<button class="fab-item" onclick="openKeyboardSettings(); toggleFabMenu()">KB</button>
</div>
</div>
<button class="keyboard-btn pc-only" onclick="openKeyboardSettings()">KB</button>
<button class="streaming-btn pc-only" onclick="toggleStreamingPanel()">STREAM</button>
<button class="upload-btn pc-only"
onclick="document.getElementById('file-upload').click()">UPLOAD</button>
<input type="file" id="file-upload" accept=".mp3,.m4a,.wav,.flac,.ogg" multiple style="display:none"
onchange="handleFileUpload(event)"> onchange="handleFileUpload(event)">
<button class="settings-btn" onclick="toggleSettings()">SET</button> <button class="settings-btn pc-only" onclick="toggleSettings()">SET</button>
<button class="library-toggle-mob" style="display:none" onclick="toggleMobileLibrary()">LIBRARY</button>
</body> </body>
</html> </html>

View File

@ -1,31 +1,31 @@
#!/bin/bash #!/bin/bash
# TechDJ PyQt5 Launcher Script # TechDJ PyQt6 Launcher Script
echo "🎧 TechDJ PyQt5 - Native DJ Application" echo "TechDJ PyQt6 - Native DJ Application"
echo "========================================" echo "========================================"
echo "" echo ""
# Activate virtual environment if it exists # Activate virtual environment if it exists
if [ -f "./.venv/bin/activate" ]; then if [ -f "./.venv/bin/activate" ]; then
echo "🔧 Activating virtual environment (.venv)..." echo "Activating virtual environment (.venv)..."
source ./.venv/bin/activate source ./.venv/bin/activate
elif [ -f "./venv/bin/activate" ]; then elif [ -f "./venv/bin/activate" ]; then
echo "🔧 Activating virtual environment (venv)..." echo "Activating virtual environment (venv)..."
source ./venv/bin/activate source ./venv/bin/activate
fi fi
# Check if Python is installed # Check if Python is installed
if ! command -v python3 &> /dev/null; then if ! command -v python3 &> /dev/null; then
echo " Python 3 is not installed!" echo "[ERROR] Python 3 is not installed!"
echo "Please install Python 3 first." echo "Please install Python 3 first."
exit 1 exit 1
fi fi
echo " Python 3 found: $(python3 --version)" echo "[OK] Python 3 found: $(python3 --version)"
# Check if pip is installed # Check if pip is installed
if ! command -v pip3 &> /dev/null; then if ! command -v pip3 &> /dev/null; then
echo "⚠️ pip3 not found. Installing..." echo "[WARN] pip3 not found. Installing..."
echo "Please run: sudo apt install python3-pip" echo "Please run: sudo apt install python3-pip"
exit 1 exit 1
fi fi
@ -36,55 +36,63 @@ echo "Checking dependencies..."
MISSING_DEPS=0 MISSING_DEPS=0
# Check PyQt5 # Check PyQt6
if ! python3 -c "import PyQt5" 2>/dev/null; then if ! python3 -c "import PyQt6" 2>/dev/null; then
echo "❌ PyQt5 not installed" echo "[ERROR] PyQt6 not installed"
MISSING_DEPS=1 MISSING_DEPS=1
else else
echo "✅ PyQt5 installed" echo "[OK] PyQt6 installed"
fi fi
# Check sounddevice # Check sounddevice
if ! python3 -c "import sounddevice" 2>/dev/null; then if ! python3 -c "import sounddevice" 2>/dev/null; then
echo " sounddevice not installed" echo "[ERROR] sounddevice not installed"
MISSING_DEPS=1 MISSING_DEPS=1
else else
echo " sounddevice installed" echo "[OK] sounddevice installed"
fi fi
# Check soundfile # Check soundfile
if ! python3 -c "import soundfile" 2>/dev/null; then if ! python3 -c "import soundfile" 2>/dev/null; then
echo " soundfile not installed" echo "[ERROR] soundfile not installed"
MISSING_DEPS=1 MISSING_DEPS=1
else else
echo " soundfile installed" echo "[OK] soundfile installed"
fi fi
# Check numpy # Check numpy
if ! python3 -c "import numpy" 2>/dev/null; then if ! python3 -c "import numpy" 2>/dev/null; then
echo " numpy not installed" echo "[ERROR] numpy not installed"
MISSING_DEPS=1 MISSING_DEPS=1
else else
echo " numpy installed" echo "[OK] numpy installed"
fi fi
# Check socketio # Check socketio
if ! python3 -c "import socketio" 2>/dev/null; then if ! python3 -c "import socketio" 2>/dev/null; then
echo " python-socketio not installed" echo "[ERROR] python-socketio not installed"
MISSING_DEPS=1 MISSING_DEPS=1
else else
echo "✅ python-socketio installed" echo "[OK] python-socketio installed"
fi
# Check yt-dlp
if ! python3 -c "import yt_dlp" 2>/dev/null; then
echo "[ERROR] yt-dlp not installed"
MISSING_DEPS=1
else
echo "[OK] yt-dlp installed"
fi fi
# Install missing dependencies # Install missing dependencies
if [ $MISSING_DEPS -eq 1 ]; then if [ $MISSING_DEPS -eq 1 ]; then
if [[ "$*" == *"--noint"* ]]; then if [[ "$*" == *"--noint"* ]]; then
echo "⚠️ Missing dependencies detected in non-interactive mode" echo "[WARN] Missing dependencies detected in non-interactive mode"
echo "Please run './launch_qt.sh' from terminal to install dependencies" echo "Please run './launch_qt.sh' from terminal to install dependencies"
# Continue anyway - dependencies might be installed elsewhere # Continue anyway - dependencies might be installed elsewhere
else else
echo "" echo ""
echo "📦 Installing missing dependencies..." echo "[INSTALL] Installing missing dependencies..."
echo "This may take a few minutes..." echo "This may take a few minutes..."
echo "" echo ""
@ -92,27 +100,27 @@ if [ $MISSING_DEPS -eq 1 ]; then
echo "Installing system dependencies..." echo "Installing system dependencies..."
if command -v apt-get &> /dev/null; then if command -v apt-get &> /dev/null; then
echo "Detected Debian/Ubuntu system" echo "Detected Debian/Ubuntu system"
echo "You may need to run: sudo apt-get install portaudio19-dev python3-pyqt5" echo "You may need to run: sudo apt-get install portaudio19-dev python3-pyqt6 python3-pyqt6.qtmultimedia libqt6multimedia6-plugins"
elif command -v dnf &> /dev/null; then elif command -v dnf &> /dev/null; then
echo "Detected Fedora system" echo "Detected Fedora system"
echo "You may need to run: sudo dnf install portaudio-devel python3-qt5" echo "You may need to run: sudo dnf install portaudio-devel python3-qt6"
elif command -v pacman &> /dev/null; then elif command -v pacman &> /dev/null; then
echo "Detected Arch system" echo "Detected Arch system"
echo "You may need to run: sudo pacman -S portaudio python-pyqt5" echo "You may need to run: sudo pacman -S portaudio python-pyqt6"
fi fi
echo "" echo ""
echo "Installing Python packages..." echo "Installing Python packages..."
pip3 install --user PyQt5 sounddevice soundfile numpy python-socketio[client] requests pip3 install --user PyQt6 sounddevice soundfile numpy python-socketio[client] requests yt-dlp
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo " Installation failed!" echo "[ERROR] Installation failed!"
echo "Please install dependencies manually:" echo "Please install dependencies manually:"
echo " pip3 install --user -r requirements.txt" echo " pip3 install --user -r requirements.txt"
exit 1 exit 1
fi fi
echo " Dependencies installed successfully!" echo "[OK] Dependencies installed successfully!"
fi fi
fi fi
@ -140,9 +148,9 @@ fi
echo "Checking server at: $SERVER_URL" echo "Checking server at: $SERVER_URL"
if curl -s --max-time 2 "${SERVER_URL}/library.json" > /dev/null 2>&1; then if curl -s --max-time 2 "${SERVER_URL}/library.json" > /dev/null 2>&1; then
echo " Flask server is running at $SERVER_URL" echo "[OK] Flask server is running at $SERVER_URL"
else else
echo "⚠️ Flask server not detected at $SERVER_URL" echo "[WARN] Flask server not detected at $SERVER_URL"
if [[ "$*" == *"--noint"* ]]; then if [[ "$*" == *"--noint"* ]]; then
echo "Proceeding in non-interactive mode..." echo "Proceeding in non-interactive mode..."
else else
@ -158,10 +166,10 @@ fi
# Launch the application # Launch the application
echo "" echo ""
echo "🚀 Launching TechDJ PyQt5..." echo "Launching TechDJ PyQt6..."
echo "" echo ""
python3 techdj_qt.py python3 techdj_qt.py
echo "" echo ""
echo "👋 TechDJ PyQt5 closed" echo "TechDJ PyQt6 closed"

View File

@ -4,10 +4,11 @@ flask-socketio
eventlet eventlet
python-dotenv python-dotenv
# PyQt5 Native App Dependencies # PyQt6 Native App Dependencies
PyQt5 PyQt6
sounddevice sounddevice
soundfile soundfile
numpy numpy
requests requests
python-socketio[client] python-socketio[client]
yt-dlp

631
script.js

File diff suppressed because it is too large Load Diff

354
server.py
View File

@ -44,29 +44,30 @@ listener_sids = set()
dj_sids = set() dj_sids = set()
# === Optional MP3 fallback stream (server-side transcoding) === # === Optional MP3 fallback stream (server-side transcoding) ===
# This allows listeners on browsers that don't support WebM/Opus via MediaSource
# (notably some Safari / locked-down environments) to still hear the stream.
_ffmpeg_proc = None _ffmpeg_proc = None
_ffmpeg_in_q = queue.Queue(maxsize=40) _ffmpeg_in_q = queue.Queue(maxsize=20) # Optimized for low-latency live streaming
_current_bitrate = "192k" _current_bitrate = "192k"
_mp3_clients = set() # set[queue.Queue] _mp3_clients = set() # set[queue.Queue]
_mp3_lock = threading.Lock() _mp3_lock = threading.Lock()
_transcode_threads_started = False
_transcoder_bytes_out = 0 _transcoder_bytes_out = 0
_transcoder_last_error = None _transcoder_last_error = None
_last_audio_chunk_ts = 0.0 _last_audio_chunk_ts = 0.0
_mp3_preroll = collections.deque(maxlen=256) # Pre-roll (~10s at 192k with 1KB chunks) _mp3_preroll = collections.deque(maxlen=512) # Larger pre-roll (~512KB)
def _start_transcoder_if_needed(is_mp3_input=False): def _start_transcoder_if_needed(is_mp3_input=False):
global _ffmpeg_proc, _transcode_threads_started, _transcoder_last_error global _ffmpeg_proc, _transcoder_last_error
# If already running, check if we need to restart for mode change # If already running, check if we need to restart
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None: if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
return return
# Local broadcast mode: input from pipe (Relay mode removed) # Ensure stale process is cleaned up
# If input is already MP3, we just use 'copy' to avoid double-encoding if _ffmpeg_proc:
try: _ffmpeg_proc.terminate()
except: pass
_ffmpeg_proc = None
codec = 'copy' if is_mp3_input else 'libmp3lame' codec = 'copy' if is_mp3_input else 'libmp3lame'
cmd = [ cmd = [
@ -92,7 +93,6 @@ def _start_transcoder_if_needed(is_mp3_input=False):
cmd.extend(['-b:a', _current_bitrate]) cmd.extend(['-b:a', _current_bitrate])
cmd.extend([ cmd.extend([
'-tune', 'zerolatency',
'-flush_packets', '1', '-flush_packets', '1',
'-f', 'mp3', '-f', 'mp3',
'pipe:1', 'pipe:1',
@ -108,60 +108,58 @@ def _start_transcoder_if_needed(is_mp3_input=False):
) )
except FileNotFoundError: except FileNotFoundError:
_ffmpeg_proc = None _ffmpeg_proc = None
print('⚠️ ffmpeg not found; /stream.mp3 fallback disabled') print('WARNING: ffmpeg not found; /stream.mp3 fallback disabled')
return return
mode_str = "PASSTHROUGH (copy)" if is_mp3_input else f"TRANSCODE ({_current_bitrate})" mode_str = "PASSTHROUGH (copy)" if is_mp3_input else f"TRANSCODE ({_current_bitrate})"
print(f'🎛️ ffmpeg transcoder started for /stream.mp3 ({mode_str})') print(f'INFO: ffmpeg transcoder started ({mode_str})')
# Reset error state
_transcoder_last_error = None _transcoder_last_error = None
# Always ensure threads are running if we just started/restarted the process # Clear queue to avoid stale data
# Clear the input queue to avoid old data being sent to new process
while not _ffmpeg_in_q.empty(): while not _ffmpeg_in_q.empty():
try: _ffmpeg_in_q.get_nowait() try: _ffmpeg_in_q.get_nowait()
except: break except: break
def _writer(): # Define threads INSIDE so they close over THIS specific 'proc'
global _transcoder_last_error, _transcode_threads_started def _writer(proc):
print("🧵 Transcoder writer thread started") global _transcoder_last_error
while True: print(f"[THREAD] Transcoder writer started (PID: {proc.pid})")
while proc.poll() is None:
try: try:
chunk = _ffmpeg_in_q.get(timeout=2) chunk = _ffmpeg_in_q.get(timeout=1.0)
if chunk is None: if chunk is None: break
break
proc = _ffmpeg_proc if proc.stdin:
if proc is None or proc.stdin is None or proc.poll() is not None: proc.stdin.write(chunk)
continue proc.stdin.flush()
proc.stdin.write(chunk)
proc.stdin.flush()
except queue.Empty: except queue.Empty:
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
break
continue continue
except Exception as e: except (BrokenPipeError, ConnectionResetError):
if _ffmpeg_proc is not None: _transcoder_last_error = "Broken pipe in writer"
print(f"⚠️ Transcoder writer error: {e}")
_transcoder_last_error = f'stdin write failed: {e}'
break break
_transcode_threads_started = False except Exception as e:
print("🧵 Transcoder writer thread exiting") print(f"WARNING: Transcoder writer error: {e}")
_transcoder_last_error = str(e)
break
# Ensure process is killed if thread exits unexpectedly
if proc.poll() is None:
try: proc.terminate()
except: pass
print(f"[THREAD] Transcoder writer finished (PID: {proc.pid})")
def _reader(): def _reader(proc):
global _transcoder_bytes_out, _transcoder_last_error, _transcode_threads_started global _transcoder_bytes_out, _transcoder_last_error
print("🧵 Transcoder reader thread started") print(f"[THREAD] Transcoder reader started (PID: {proc.pid})")
proc = _ffmpeg_proc while proc.poll() is None:
while proc and proc.poll() is None:
try: try:
# Smaller read for smoother delivery (1KB) # Smaller read for smoother delivery (1KB)
# This prevents buffering delays at lower bitrates # This prevents buffering delays at lower bitrates
data = proc.stdout.read(1024) data = proc.stdout.read(1024)
if not data: if not data: break
break
_transcoder_bytes_out += len(data) _transcoder_bytes_out += len(data)
# Store in pre-roll
with _mp3_lock: with _mp3_lock:
_mp3_preroll.append(data) _mp3_preroll.append(data)
clients = list(_mp3_clients) clients = list(_mp3_clients)
@ -169,87 +167,180 @@ def _start_transcoder_if_needed(is_mp3_input=False):
for q in clients: for q in clients:
try: try:
q.put_nowait(data) q.put_nowait(data)
except Exception: except queue.Full:
# Client is too slow, skip this chunk for them
pass pass
except Exception as e: except Exception as e:
print(f"⚠️ Transcoder reader error: {e}") print(f"WARNING: Transcoder reader error: {e}")
_transcoder_last_error = f'stdout read failed: {e}' _transcoder_last_error = str(e)
break break
_transcode_threads_started = False
print("🧵 Transcoder reader thread exiting")
if not _transcode_threads_started: # Ensure process is killed if thread exits unexpectedly
_transcode_threads_started = True if proc.poll() is None:
threading.Thread(target=_writer, daemon=True).start() try: proc.terminate()
threading.Thread(target=_reader, daemon=True).start() except: pass
print(f"[THREAD] Transcoder reader finished (PID: {proc.pid})")
# Start greenlets/threads for THIS process specifically
eventlet.spawn(_writer, _ffmpeg_proc)
eventlet.spawn(_reader, _ffmpeg_proc)
def _stop_transcoder(): def _stop_transcoder():
global _ffmpeg_proc, _transcode_threads_started global _ffmpeg_proc
try: print("STOPPING: Transcoder process")
_ffmpeg_in_q.put_nowait(None)
except Exception: # Signal threads to stop via the queue
pass try: _ffmpeg_in_q.put_nowait(None)
except: pass
# Shutdown the process
proc = _ffmpeg_proc proc = _ffmpeg_proc
_ffmpeg_proc = None _ffmpeg_proc = None
# Reset thread flag so they can be re-launched if needed if proc:
# (The existing threads will exit cleanly on None/EOF) try:
_transcode_threads_started = False proc.terminate()
_mp3_preroll.clear() # Drain stdout/stderr to satisfy OS buffers
proc.communicate(timeout=1.0)
if proc is None: except:
return try: proc.kill()
except: pass
# Signal all listening clients to finish their stream
# Clear client state
with _mp3_lock: with _mp3_lock:
clients = list(_mp3_clients) clients = list(_mp3_clients)
for q in clients: for q in clients:
try: try: q.put_nowait(None)
q.put_nowait(None) except: pass
except:
pass
_mp3_clients.clear() _mp3_clients.clear()
_mp3_preroll.clear()
try:
proc.terminate()
except Exception:
pass
def _feed_transcoder(data: bytes): def _feed_transcoder(data: bytes):
global _last_audio_chunk_ts global _last_audio_chunk_ts
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
return # If active but dead, restart it automatically
if broadcast_state.get('active'):
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
else:
return
_last_audio_chunk_ts = time.time() _last_audio_chunk_ts = time.time()
try: try:
_ffmpeg_in_q.put_nowait(data) _ffmpeg_in_q.put_nowait(data)
except Exception: except queue.Full:
# Queue full; drop to keep latency bounded. # Drop chunk if overflow to prevent memory bloat
pass pass
MUSIC_FOLDER = "music" # Load settings to get MUSIC_FOLDER
def _load_settings():
try:
if os.path.exists('settings.json'):
with open('settings.json', 'r', encoding='utf-8') as f:
return json.load(f)
except:
pass
return {}
SETTINGS = _load_settings()
MUSIC_FOLDER = 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):
os.makedirs(MUSIC_FOLDER) try:
os.makedirs(MUSIC_FOLDER)
except:
# Fallback to default if custom path fails
MUSIC_FOLDER = "music"
if not os.path.exists(MUSIC_FOLDER):
os.makedirs(MUSIC_FOLDER)
# Helper for shared routes # Helper for shared routes
def setup_shared_routes(app): def setup_shared_routes(app):
@app.route('/library.json') @app.route('/library.json')
def get_library(): def get_library():
library = [] library = []
global MUSIC_FOLDER
if os.path.exists(MUSIC_FOLDER): if os.path.exists(MUSIC_FOLDER):
for filename in sorted(os.listdir(MUSIC_FOLDER)): # Recursively find music files if desired, or stay top-level.
if filename.lower().endswith(('.mp3', '.m4a', '.wav', '.flac', '.ogg')): # The prompt says "choose which folder", so maybe top-level of that folder is fine.
library.append({ for root, dirs, files in os.walk(MUSIC_FOLDER):
"title": os.path.splitext(filename)[0], for filename in sorted(files):
"file": f"music/{filename}" if filename.lower().endswith(('.mp3', '.m4a', '.wav', '.flac', '.ogg')):
}) rel_path = os.path.relpath(os.path.join(root, filename), MUSIC_FOLDER)
library.append({
"title": os.path.splitext(filename)[0],
"file": f"music_proxy/{rel_path}"
})
break # Only top level for now to keep it simple, or remove break for recursive
return jsonify(library) return jsonify(library)
@app.route('/music_proxy/<path:filename>')
def music_proxy(filename):
return send_from_directory(MUSIC_FOLDER, filename)
@app.route('/browse_directories', methods=['GET'])
def browse_directories():
path = request.args.get('path', os.path.expanduser('~'))
try:
entries = []
if os.path.exists(path) and os.path.isdir(path):
# Add parent
parent = os.path.dirname(os.path.abspath(path))
entries.append({"name": "..", "path": parent, "isDir": True})
for item in sorted(os.listdir(path)):
full_path = os.path.join(path, item)
if os.path.isdir(full_path) and not item.startswith('.'):
entries.append({
"name": item,
"path": full_path,
"isDir": True
})
return jsonify({"success": True, "path": os.path.abspath(path), "entries": entries})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/update_settings', methods=['POST'])
def update_settings():
try:
data = request.get_json()
# Load existing
settings = _load_settings()
# Update selectively
if 'library' not in settings: settings['library'] = {}
if 'music_folder' in data.get('library', {}):
new_folder = data['library']['music_folder']
if os.path.exists(new_folder) and os.path.isdir(new_folder):
settings['library']['music_folder'] = new_folder
global MUSIC_FOLDER
MUSIC_FOLDER = new_folder
else:
return jsonify({"success": False, "error": "Invalid folder path"}), 400
with open('settings.json', 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=4)
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@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
if '..' in filename or filename.startswith('/'):
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'
@ -269,24 +360,26 @@ def setup_shared_routes(app):
if file.filename == '': if file.filename == '':
return jsonify({"success": False, "error": "No file selected"}), 400 return jsonify({"success": False, "error": "No file selected"}), 400
if not file.filename.endswith('.mp3'): allowed_exts = ('.mp3', '.m4a', '.wav', '.flac', '.ogg')
return jsonify({"success": False, "error": "Only MP3 files are allowed"}), 400 ext = os.path.splitext(file.filename)[1].lower()
if ext not in allowed_exts:
return jsonify({"success": False, "error": f"Supported formats: {', '.join(allowed_exts)}"}), 400
# Sanitize filename (keep extension) # Sanitize filename (keep extension)
import re 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) name_without_ext = re.sub(r'\s+', ' ', name_without_ext).strip()
filename = f"{name_without_ext}.mp3" filename = f"{name_without_ext}{ext}"
filepath = os.path.join(MUSIC_FOLDER, filename) filepath = os.path.join(MUSIC_FOLDER, filename)
try: try:
file.save(filepath) file.save(filepath)
print(f"✅ Uploaded: {filename}") print(f"UPLOADED: {filename}")
return jsonify({"success": True, "filename": filename}) return jsonify({"success": True, "filename": filename})
except Exception as e: except Exception as e:
print(f" Upload error: {e}") print(f"ERROR: Upload error: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/save_keymaps', methods=['POST']) @app.route('/save_keymaps', methods=['POST'])
@ -295,10 +388,10 @@ def setup_shared_routes(app):
data = request.get_json() data = request.get_json()
with open('keymaps.json', 'w', encoding='utf-8') as f: with open('keymaps.json', 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
print("💾 Keymaps saved to keymaps.json") print("SAVED: Keymaps saved to keymaps.json")
return jsonify({"success": True}) return jsonify({"success": True})
except Exception as e: except Exception as e:
print(f" Save keymaps error: {e}") print(f"ERROR: Save keymaps error: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/load_keymaps', methods=['GET']) @app.route('/load_keymaps', methods=['GET'])
@ -311,7 +404,7 @@ def setup_shared_routes(app):
else: else:
return jsonify({"success": True, "keymaps": None}) return jsonify({"success": True, "keymaps": None})
except Exception as e: except Exception as e:
print(f" Load keymaps error: {e}") print(f"ERROR: Load keymaps error: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/stream.mp3') @app.route('/stream.mp3')
@ -321,8 +414,8 @@ def setup_shared_routes(app):
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
return jsonify({"success": False, "error": "MP3 stream not available"}), 503 return jsonify({"success": False, "error": "MP3 stream not available"}), 503
print(f"👂 New listener joined stream (Bursting {_mp3_preroll.maxlen} frames)") print(f"LISTENER: New listener joined stream (Bursting {_mp3_preroll.maxlen} frames)")
client_q: queue.Queue = queue.Queue(maxsize=100) client_q: queue.Queue = queue.Queue(maxsize=500)
with _mp3_lock: with _mp3_lock:
# Burst pre-roll to new client so they start playing instantly # Burst pre-roll to new client so they start playing instantly
for chunk in _mp3_preroll: for chunk in _mp3_preroll:
@ -335,13 +428,20 @@ def setup_shared_routes(app):
def gen(): def gen():
try: try:
while True: while True:
chunk = client_q.get() try:
chunk = client_q.get(timeout=30)
except queue.Empty:
# No data for 30s - check if broadcast is still active
if not broadcast_state.get('active'):
break
continue
if chunk is None: if chunk is None:
break break
yield chunk yield chunk
finally: finally:
with _mp3_lock: with _mp3_lock:
_mp3_clients.discard(client_q) _mp3_clients.discard(client_q)
print(f"LISTENER: Listener disconnected from stream")
return Response(stream_with_context(gen()), content_type='audio/mpeg', headers={ return Response(stream_with_context(gen()), content_type='audio/mpeg', headers={
'Cache-Control': 'no-cache, no-store, must-revalidate', 'Cache-Control': 'no-cache, no-store, must-revalidate',
@ -482,15 +582,15 @@ dj_socketio = SocketIO(
@dj_socketio.on('connect') @dj_socketio.on('connect')
def dj_connect(): def dj_connect():
if DJ_AUTH_ENABLED and session.get('dj_authed') is not True: if DJ_AUTH_ENABLED and session.get('dj_authed') is not True:
print(f" DJ socket rejected (unauthorized): {request.sid}") print(f"REJECTED: DJ socket rejected (unauthorized): {request.sid}")
return False return False
print(f"🎧 DJ connected: {request.sid}") print(f"STREAMPANEL: DJ connected: {request.sid}")
dj_sids.add(request.sid) dj_sids.add(request.sid)
@dj_socketio.on('disconnect') @dj_socketio.on('disconnect')
def dj_disconnect(): def dj_disconnect():
dj_sids.discard(request.sid) dj_sids.discard(request.sid)
print("⚠️ DJ disconnected - broadcast will continue until manually stopped") print("WARNING: DJ disconnected - broadcast will continue until manually stopped")
def stop_broadcast_after_timeout(): def stop_broadcast_after_timeout():
"""No longer used - broadcasts don't auto-stop""" """No longer used - broadcasts don't auto-stop"""
@ -500,19 +600,21 @@ def stop_broadcast_after_timeout():
def dj_start(data=None): def dj_start(data=None):
broadcast_state['active'] = True broadcast_state['active'] = True
session['is_dj'] = True session['is_dj'] = True
print("🎙️ Broadcast -> ACTIVE") print("BROADCAST: ACTIVE")
is_mp3_input = False is_mp3_input = False
if data: if data:
if 'bitrate' in data: if 'bitrate' in data:
global _current_bitrate global _current_bitrate
_current_bitrate = data['bitrate'] _current_bitrate = data['bitrate']
print(f"📡 Setting stream bitrate to: {_current_bitrate}") print(f"BITRATE: Setting stream bitrate to: {_current_bitrate}")
if data.get('format') == 'mp3': if data.get('format') == 'mp3':
is_mp3_input = True is_mp3_input = True
broadcast_state['is_mp3_input'] = is_mp3_input
# Clear pre-roll for fresh start # Clear pre-roll for fresh start
_mp3_preroll.clear() with _mp3_lock:
_mp3_preroll.clear()
_start_transcoder_if_needed(is_mp3_input=is_mp3_input) _start_transcoder_if_needed(is_mp3_input=is_mp3_input)
@ -527,16 +629,13 @@ def dj_get_listener_count():
def dj_stop(): def dj_stop():
broadcast_state['active'] = False broadcast_state['active'] = False
session['is_dj'] = False session['is_dj'] = False
print("🛑 DJ stopped broadcasting") print("STOPPED: DJ stopped broadcasting")
_stop_transcoder() _stop_transcoder()
listener_socketio.emit('broadcast_stopped', namespace='/') listener_socketio.emit('broadcast_stopped', namespace='/')
listener_socketio.emit('stream_status', {'active': False}, namespace='/') listener_socketio.emit('stream_status', {'active': False}, namespace='/')
listener_socketio.emit('broadcast_stopped', namespace='/')
listener_socketio.emit('stream_status', {'active': False}, namespace='/')
@dj_socketio.on('audio_chunk') @dj_socketio.on('audio_chunk')
def dj_audio(data): def dj_audio(data):
# MP3-only mode: do not relay raw chunks to listeners; feed transcoder only. # MP3-only mode: do not relay raw chunks to listeners; feed transcoder only.
@ -554,6 +653,15 @@ def dj_audio(data):
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'] = 'listener_secret'
setup_shared_routes(listener_app) setup_shared_routes(listener_app)
# Block write/admin endpoints on the listener server
@listener_app.before_request
def _restrict_listener_routes():
"""Prevent listeners from accessing DJ-only write endpoints."""
blocked_paths = ('/update_settings', '/upload', '/save_keymaps', '/browse_directories')
if request.path in blocked_paths:
from flask import abort
abort(403)
listener_socketio = SocketIO( listener_socketio = SocketIO(
listener_app, listener_app,
cors_allowed_origins="*", cors_allowed_origins="*",
@ -567,13 +675,13 @@ listener_socketio = SocketIO(
@listener_socketio.on('connect') @listener_socketio.on('connect')
def listener_connect(): def listener_connect():
print(f"👂 Listener Socket Connected: {request.sid}") print(f"LISTENER: Listener Socket Connected: {request.sid}")
@listener_socketio.on('disconnect') @listener_socketio.on('disconnect')
def listener_disconnect(): def listener_disconnect():
listener_sids.discard(request.sid) listener_sids.discard(request.sid)
count = len(listener_sids) count = len(listener_sids)
print(f" Listener left. Total: {count}") print(f"REMOVED: Listener left. Total: {count}")
# Notify BOTH namespaces # Notify BOTH namespaces
listener_socketio.emit('listener_count', {'count': count}, namespace='/') listener_socketio.emit('listener_count', {'count': count}, namespace='/')
dj_socketio.emit('listener_count', {'count': count}, namespace='/') dj_socketio.emit('listener_count', {'count': count}, namespace='/')
@ -583,7 +691,7 @@ def listener_join():
if request.sid not in listener_sids: if request.sid not in listener_sids:
listener_sids.add(request.sid) listener_sids.add(request.sid)
count = len(listener_sids) count = len(listener_sids)
print(f"👂 New listener joined. Total: {count}") print(f"LISTENER: New listener joined. Total: {count}")
listener_socketio.emit('listener_count', {'count': count}, namespace='/') listener_socketio.emit('listener_count', {'count': count}, namespace='/')
dj_socketio.emit('listener_count', {'count': count}, namespace='/') dj_socketio.emit('listener_count', {'count': count}, namespace='/')
@ -594,13 +702,16 @@ def listener_get_count():
emit('listener_count', {'count': len(listener_sids)}) emit('listener_count', {'count': len(listener_sids)})
# DJ Panel Routes (No engine commands needed in local mode) # DJ Panel Routes (No engine commands needed in local mode)
@dj_socketio.on('get_mixer_status') def _transcoder_watchdog():
def get_mixer_status(): """Periodic check to ensure the transcoder stays alive during active broadcasts."""
pass while True:
if broadcast_state.get('active'):
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
# Only log if it's actually dead and supposed to be alive
print("WARNING: Watchdog: Transcoder dead during active broadcast, reviving...")
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
eventlet.sleep(5)
@dj_socketio.on('audio_sync_queue')
def audio_sync_queue(data):
pass
def _listener_count_sync_loop(): def _listener_count_sync_loop():
"""Periodic background sync to ensure listener count is always accurate.""" """Periodic background sync to ensure listener count is always accurate."""
@ -613,20 +724,21 @@ def _listener_count_sync_loop():
if __name__ == '__main__': 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 # Ports from environment or defaults
dj_port = int(os.environ.get('DJ_PORT', 5000)) dj_port = int(os.environ.get('DJ_PORT', 5000))
listen_port = int(os.environ.get('LISTEN_PORT', 5001)) listen_port = int(os.environ.get('LISTEN_PORT', 5001))
print(f"👉 DJ PANEL API: http://0.0.0.0:{dj_port}") print(f"URL: DJ PANEL API: http://0.0.0.0:{dj_port}")
print(f"👉 LISTEN PAGE: http://0.0.0.0:{listen_port}") print(f"URL: LISTEN PAGE: http://0.0.0.0:{listen_port}")
print("=" * 50) print("=" * 50)
# Audio engine DISABLED # Audio engine DISABLED
print(f" Local Radio server ready on ports {dj_port} & {listen_port}") 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(dj_socketio.run, dj_app, host='0.0.0.0', port=dj_port, debug=False) 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) listener_socketio.run(listener_app, host='0.0.0.0', port=listen_port, debug=False)

22
settings.json Normal file
View File

@ -0,0 +1,22 @@
{
"shortcuts": {
"Deck A: Load": "Ctrl+L",
"Deck A: Queue": "Ctrl+Shift+L",
"Deck A: Play/Pause": "Space",
"Deck B: Load": "Ctrl+R",
"Deck B: Queue": "Ctrl+Shift+R",
"Deck B: Play/Pause": "Ctrl+Space"
},
"audio": {
"recording_sample_rate": 48000,
"recording_format": "wav",
"stream_server_url": "http://54.37.246.24:5000"
},
"ui": {
"neon_mode": 2
},
"library": {
"auto_scan": true,
"yt_default_format": "mp3"
}
}

811
style.css

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

2801
techdj_qt_v1.py.bak Normal file

File diff suppressed because one or more lines are too long