diff --git a/techdj_qt.py b/techdj_qt.py index 5e28f5c..6ece249 100644 --- a/techdj_qt.py +++ b/techdj_qt.py @@ -9,6 +9,7 @@ import shutil import requests import re import socketio +from urllib.parse import urlparse import subprocess import atexit from pathlib import Path @@ -250,6 +251,50 @@ def get_audio_capture_source(): return 'default.monitor' +def _route_qt_audio_to_virtual_sink(): + """Move any PulseAudio/PipeWire sink-inputs from this process to the + virtual techdj_stream sink. + + Needed because Qt6 may output audio via the PipeWire-native backend + instead of the PulseAudio compatibility layer, which means PULSE_SINK + has no effect and the monitor source used by the streaming ffmpeg would + capture silence instead of the DJ's music. Calling pactl move-sink-input + works regardless of whether the client originally used PulseAudio or + PipeWire-native, as PipeWire exposes a PulseAudio-compatible socket. + """ + if not _audio_isolated: + return + pid_str = str(os.getpid()) + try: + result = subprocess.run( + ['pactl', 'list', 'sink-inputs'], + capture_output=True, text=True, timeout=3, + ) + current_id = None + current_app_pid = None + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith('Sink Input #'): + # Flush previous block + if current_id and current_app_pid == pid_str: + subprocess.run( + ['pactl', 'move-sink-input', current_id, _AUDIO_SINK_NAME], + capture_output=True, timeout=3, + ) + current_id = stripped.split('#')[1].strip() + current_app_pid = None + elif 'application.process.id' in stripped and '"' in stripped: + current_app_pid = stripped.split('"')[1] + # Handle last block + if current_id and current_app_pid == pid_str: + subprocess.run( + ['pactl', 'move-sink-input', current_id, _AUDIO_SINK_NAME], + capture_output=True, timeout=3, + ) + except Exception: + pass + + atexit.register(_teardown_audio_isolation) @@ -582,9 +627,27 @@ class SettingsDialog(QDialog): border-radius: 4px; } """) - + + pw_label = QLabel("DJ Panel Password (leave blank if none):") + self.dj_password_input = QLineEdit() + self.dj_password_input.setEchoMode(QLineEdit.EchoMode.Password) + self.dj_password_input.setPlaceholderText("Leave blank if no password is set") + self.dj_password_input.setText(self.audio_settings.get("dj_panel_password", "")) + self.dj_password_input.setStyleSheet(""" + QLineEdit { + background: #1a1a1a; + border: 1px solid #333; + padding: 8px; + color: #fff; + border-radius: 4px; + } + """) + layout.addWidget(stream_url_label) layout.addWidget(self.stream_url_input) + layout.addSpacing(8) + layout.addWidget(pw_label) + layout.addWidget(self.dj_password_input) layout.addSpacing(20) # Recording section @@ -684,6 +747,7 @@ class SettingsDialog(QDialog): "recording_sample_rate": self.sample_rate_combo.currentData(), "recording_format": self.format_combo.currentData(), "stream_server_url": self.stream_url_input.text(), + "dj_panel_password": self.dj_password_input.text(), }, "ui": { "neon_mode": self.neon_combo.currentData(), @@ -805,11 +869,13 @@ class StreamingWorker(QThread): streaming_started = pyqtSignal() streaming_error = pyqtSignal(str) listener_count = pyqtSignal(int) - + def __init__(self, parent=None): super().__init__(parent) self.sio = None self.stream_url = "" + self.dj_password = "" + self.glow_intensity = 30 # mirrors the UI slider; sent to listeners on connect self.is_running = False self.ffmpeg_proc = None self._broadcast_started = False @@ -819,11 +885,14 @@ class StreamingWorker(QThread): if not self._broadcast_started: self._broadcast_started = True self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'}) + # Push current glow intensity to all listeners as soon as we connect + self.sio.emit('listener_glow', {'intensity': self.glow_intensity}) self.streaming_started.emit() else: - # Reconnected mid-stream - server handles resume gracefully + # Reconnected mid-stream — server handles resume gracefully print("[SOCKET] Reconnected - resuming existing broadcast") self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'}) + self.sio.emit('listener_glow', {'intensity': self.glow_intensity}) def on_disconnect(self): print("[SOCKET] Disconnected from DJ server") @@ -834,6 +903,42 @@ class StreamingWorker(QThread): def on_listener_count(self, data): self.listener_count.emit(data.get('count', 0)) + @staticmethod + def _get_auth_cookie(base_url, password): + """POST the DJ panel password to /login and return the session cookie string. + + The server sets a Flask session cookie on success. We forward that + cookie as an HTTP header on the Socket.IO upgrade request so the + socket handler sees an authenticated session. + """ + try: + resp = requests.post( + f"{base_url}/login", + data={"password": password}, + allow_redirects=False, + timeout=5, + ) + # Server responds with 302 + Set-Cookie on success + if resp.cookies: + return "; ".join(f"{k}={v}" for k, v in resp.cookies.items()) + print(f"[AUTH] Login response {resp.status_code} — no session cookie returned") + return None + except Exception as e: + print(f"[AUTH] Login request failed: {e}") + return None + + @staticmethod + def _drain_stderr(proc): + """Read ffmpeg's stderr in a daemon thread so the OS pipe buffer never + fills up and deadlocks the stdout read loop.""" + try: + for raw_line in iter(proc.stderr.readline, b''): + line = raw_line.decode('utf-8', errors='ignore').strip() + if line: + print(f"[STREAM FFMPEG] {line}") + except Exception: + pass + def run(self): try: # Create a fresh Socket.IO client for each session @@ -843,11 +948,29 @@ class StreamingWorker(QThread): self.sio.on('listener_count', self.on_listener_count) self.sio.on('connect_error', self.on_connect_error) - self.sio.connect(self.stream_url) - + # If the DJ panel has a password set, authenticate via HTTP first to + # obtain a Flask session cookie, then forward it on the WS upgrade + # request so the socket handler sees an authenticated session. + connect_headers = {} + if self.dj_password: + print("[AUTH] DJ panel password configured — authenticating...") + cookie = self._get_auth_cookie(self.stream_url, self.dj_password) + if cookie: + connect_headers['Cookie'] = cookie + print("[AUTH] Authenticated — session cookie obtained") + else: + self.streaming_error.emit( + "Authentication failed.\n" + "Check the DJ panel password in Settings → Audio." + ) + return + + # wait_timeout: how long to wait for the server to respond during connect + self.sio.connect(self.stream_url, wait_timeout=10, headers=connect_headers) + source = get_audio_capture_source() print(f"[STREAM] Capturing from: {source}") - + cmd = [ "ffmpeg", "-hide_banner", @@ -863,46 +986,83 @@ class StreamingWorker(QThread): # Flush every packet — critical for low-latency pipe streaming "-flush_packets", "1", "-f", "mp3", - "pipe:1" + "pipe:1", ] self.ffmpeg_proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0 ) - + + # Drain stderr in a real daemon thread so the OS pipe buffer never + # fills up and blocks stdout (classic Python subprocess deadlock). + import threading as _threading + stderr_thread = _threading.Thread( + target=self._drain_stderr, args=(self.ffmpeg_proc,), daemon=True + ) + stderr_thread.start() + while self.is_running and self.ffmpeg_proc.poll() is None: - # 4096 bytes ≈ 10 MP3 frames ≈ ~260ms at 128kbps — low-latency chunks + # 4096 bytes ≈ 10 MP3 frames ≈ ~260 ms at 128 kbps — low-latency chunks chunk = self.ffmpeg_proc.stdout.read(4096) if not chunk: break - sio = self.sio # Local ref to avoid race with stop_streaming() + sio = self.sio # local ref guards against stop_streaming() race if sio and sio.connected: sio.emit('audio_chunk', chunk) - + + # Detect unexpected ffmpeg exit during an active stream + if self.is_running: + ret = self.ffmpeg_proc.poll() if self.ffmpeg_proc else None + if ret is not None and ret != 0: + self.streaming_error.emit( + f"FFmpeg exited with code {ret}.\n" + "Check that PulseAudio / PipeWire is running and the " + "virtual audio sink was created successfully." + ) + except Exception as e: - self.streaming_error.emit(f"Streaming thread error: {e}") + self.streaming_error.emit(f"Streaming error: {e}") finally: self.stop_streaming() - def start_streaming(self, base_url, bitrate=128): + def start_streaming(self, base_url, bitrate=128, password=""): self.stream_url = base_url + self.dj_password = password self.is_running = True self.start() return True + def emit_if_connected(self, event, data=None): + """Thread-safe emit from any thread — no-op if socket is not connected.""" + sio = self.sio + if sio and sio.connected: + try: + sio.emit(event, data) + except Exception as e: + print(f"[SOCKET] emit_if_connected error: {e}") + def stop_streaming(self): + """Thread-safe stop: capture refs locally before clearing to avoid TOCTOU.""" self.is_running = False self._broadcast_started = False - if self.ffmpeg_proc: - try: self.ffmpeg_proc.terminate() - except: pass - self.ffmpeg_proc = None - if self.sio and self.sio.connected: + + proc = self.ffmpeg_proc + self.ffmpeg_proc = None + sio = self.sio + self.sio = None + + if proc: try: - self.sio.emit('stop_broadcast') - self.sio.disconnect() + proc.terminate() + except Exception: + pass + + if sio: + try: + if sio.connected: + sio.emit('stop_broadcast') + sio.disconnect() except Exception: pass - self.sio = None # --- WIDGETS --- @@ -1144,7 +1304,8 @@ class DeckWidget(QGroupBox): self.loop_end = 0 self.loop_btns = [] self.xf_vol = 100 - + self.current_title = "" + self.loop_timer = QTimer(self) self.loop_timer.setInterval(LOOP_CHECK_INTERVAL) self.loop_timer.timeout.connect(self.check_loop) @@ -1379,6 +1540,7 @@ class DeckWidget(QGroupBox): try: self.player.setSource(QUrl.fromLocalFile(str(p.absolute()))) + self.current_title = p.stem self.lbl_tr.setText(p.stem.upper()) self.vinyl.set_speed(0) self.vinyl.angle = 0 @@ -1427,14 +1589,32 @@ class DeckWidget(QGroupBox): else: # Stop mode or no queue items self.stop() - + + def _emit_playing_state(self, is_playing): + """Emit deck_glow and now_playing events to the listener page.""" + mw = self.window() + if not hasattr(mw, 'streaming_worker') or not hasattr(mw, 'deck_a') or not hasattr(mw, 'deck_b'): + return + other = mw.deck_b if self.deck_id == 'A' else mw.deck_a + other_playing = (other.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState) + a_playing = is_playing if self.deck_id == 'A' else other_playing + b_playing = is_playing if self.deck_id == 'B' else other_playing + mw.streaming_worker.emit_if_connected('deck_glow', {'A': a_playing, 'B': b_playing}) + if is_playing and self.current_title: + mw.streaming_worker.emit_if_connected('now_playing', { + 'title': self.current_title, + 'deck': self.deck_id, + }) + def play(self): self.player.play() self.vinyl.start_spin() + self._emit_playing_state(True) def pause(self): self.player.pause() self.vinyl.stop_spin() + self._emit_playing_state(False) def stop(self): self.player.stop() @@ -1442,6 +1622,7 @@ class DeckWidget(QGroupBox): self.vinyl.angle = 0 self.vinyl.update() self.clear_loop() + self._emit_playing_state(False) def on_position_changed(self, pos): self.wave.set_position(pos) @@ -1531,6 +1712,15 @@ class DJApp(QMainWindow): self.streaming_worker.streaming_error.connect(self.on_streaming_error) self.streaming_worker.listener_count.connect(self.update_listener_count) self.is_streaming = False + + # Periodically ensure Qt's audio sink-inputs are routed to the virtual + # sink. Qt6 may use the PipeWire-native audio backend which ignores + # PULSE_SINK, causing the streaming ffmpeg to capture silence instead + # of the DJ's music. pactl move-sink-input fixes this regardless of + # which Qt audio backend is active. + self._audio_route_timer = QTimer() + self._audio_route_timer.timeout.connect(_route_qt_audio_to_virtual_sink) + self._audio_route_timer.start(2000) # run every 2 s # Server library state self.server_url = "http://localhost:5000" @@ -1725,7 +1915,57 @@ class DJApp(QMainWindow): stream_info = QVBoxLayout() stream_info.addWidget(self.stream_status_label) stream_info.addWidget(self.listener_count_label) - + + # --- Listener Glow Controls --- + glow_title = QLabel("LISTENER GLOW") + glow_title.setStyleSheet( + "color: #bc13fe; font-size: 10px; font-weight: bold; " + "font-family: 'Courier New'; letter-spacing: 1px;" + ) + glow_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.glow_slider = QSlider(Qt.Orientation.Horizontal) + self.glow_slider.setRange(0, 100) + self.glow_slider.setValue(30) + self.glow_slider.setFixedWidth(130) + self.glow_slider.setToolTip( + "Listener page glow intensity\n" + "0 = off | 100 = max\n" + "Sends to all listeners in real-time while streaming" + ) + self.glow_slider.setStyleSheet(""" + QSlider::groove:horizontal { + border: 1px solid #550088; + height: 6px; + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #1a0026, stop:1 #bc13fe); + border-radius: 3px; + } + QSlider::handle:horizontal { + background: #bc13fe; + border: 2px solid #e040fb; + width: 14px; + height: 14px; + margin: -5px 0; + border-radius: 7px; + } + QSlider::handle:horizontal:hover { + background: #e040fb; + border-color: #fff; + } + """) + self.glow_slider.valueChanged.connect(self.on_glow_slider_changed) + + self.glow_value_label = QLabel("30") + self.glow_value_label.setStyleSheet("color: #bc13fe; font-size: 10px;") + self.glow_value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + glow_vbox = QVBoxLayout() + glow_vbox.setSpacing(2) + glow_vbox.addWidget(glow_title) + glow_vbox.addWidget(self.glow_slider) + glow_vbox.addWidget(self.glow_value_label) + rec_layout.addStretch() rec_layout.addWidget(self.record_button) rec_layout.addSpacing(20) @@ -1734,6 +1974,8 @@ class DJApp(QMainWindow): rec_layout.addWidget(self.stream_button) rec_layout.addSpacing(20) rec_layout.addLayout(stream_info) + rec_layout.addSpacing(40) + rec_layout.addLayout(glow_vbox) rec_layout.addStretch() layout.addLayout(rec_layout, 3) @@ -2000,12 +2242,16 @@ class DJApp(QMainWindow): def fetch_server_library(self): self.library_list.clear() self.library_list.addItem("Fetching server library...") - - base_url = self.get_server_base_url() - self.server_url = base_url - - self.fetcher = ServerLibraryFetcher(f"{base_url}/library.json") - self.fetcher.finished.connect(lambda tracks, err, success: self.on_server_library_fetched(tracks, base_url, err, success)) + + # Use the listener port (no auth required) for library / track data. + # The DJ port requires a password session which the Qt client doesn't hold. + listener_url = self.get_listener_base_url() + self.server_url = self.get_server_base_url() # kept for socket/streaming + + self.fetcher = ServerLibraryFetcher(f"{listener_url}/library.json") + self.fetcher.finished.connect( + lambda tracks, err, success: self.on_server_library_fetched(tracks, listener_url, err, success) + ) self.fetcher.start() def on_server_library_fetched(self, tracks, base_url, err, success): @@ -2136,10 +2382,24 @@ class DJApp(QMainWindow): try: self.status_label.setText("Uploading to server...") base_url = self.get_server_base_url() + password = self.all_settings.get("audio", {}).get("dj_panel_password", "") + + # Authenticate if a password is configured (same approach as StreamingWorker) + session_cookie = None + if password: + session_cookie = StreamingWorker._get_auth_cookie(base_url, password) + if not session_cookie: + QMessageBox.warning(self, "Upload Error", "DJ panel authentication failed.\nCheck the password in Settings → Audio.") + self.status_label.setText("Upload failed") + return + + headers = {} + if session_cookie: + headers['Cookie'] = session_cookie with open(file_path, 'rb') as f: files = {'file': f} - response = requests.post(f"{base_url}/upload", files=files, timeout=60) + response = requests.post(f"{base_url}/upload", files=files, headers=headers, timeout=60) if response.status_code == 200: self.status_label.setText("Upload successful!") @@ -2255,12 +2515,13 @@ class DJApp(QMainWindow): def toggle_streaming(self): """Toggle live streaming on/off""" if not self.is_streaming: - # Get base URL from settings + # Get base URL and credentials from settings base_url = self.get_server_base_url() bitrate = self.all_settings.get("audio", {}).get("bitrate", 128) - - # Start streaming - if self.streaming_worker.start_streaming(base_url, bitrate): + password = self.all_settings.get("audio", {}).get("dj_panel_password", "") + + # Start streaming (password handled inside StreamingWorker) + if self.streaming_worker.start_streaming(base_url, bitrate, password=password): self.is_streaming = True self.stream_button.setText("STOP") self.stream_button.setStyleSheet(""" @@ -2315,21 +2576,62 @@ class DJApp(QMainWindow): def update_listener_count(self, count): self.listener_count_label.setText(f"{count} listeners") + def on_glow_slider_changed(self, val): + """Send listener glow intensity to all connected listeners in real-time.""" + self.glow_value_label.setText(str(val)) + # Keep the worker's glow_intensity in sync so it's sent on reconnect too + self.streaming_worker.glow_intensity = val + self.streaming_worker.emit_if_connected('listener_glow', {'intensity': val}) + + def get_listener_base_url(self): + """Return the listener server base URL (no auth required). + + The listener server runs on DJ_port + 1 by convention (default 5001). + Library JSON and music_proxy routes are available there without any + password, so we use this for all library / track-download requests. + """ + audio_settings = self.all_settings.get("audio", {}) + raw = audio_settings.get("stream_server_url", "http://localhost:5000").strip() + + if not raw.startswith(("http://", "https://", "ws://", "wss://")): + raw = "http://" + raw + + parsed = urlparse(raw) + host = parsed.hostname or "localhost" + dj_port = parsed.port or 5000 + + # Normalise: if the configured port IS the listener port, keep it; + # otherwise derive listener port as DJ port + 1. + if dj_port == 5001: + listener_port = 5001 + else: + # Remap any reverse-proxy or listener port back to DJ port first, + # then add 1 to get the listener port. + if dj_port == 8080: + dj_port = 5000 + listener_port = dj_port + 1 + + return f"http://{host}:{listener_port}" + def get_server_base_url(self): audio_settings = self.all_settings.get("audio", {}) - server_url = audio_settings.get("stream_server_url", "http://localhost:5000") - - # Normal techdj server runs on 5000 (DJ) and 5001 (Listener) - # If the URL is for the listener or stream, switch to 5000 - if ":5001" in server_url: - return server_url.split(":5001")[0] + ":5000" - elif ":8080" in server_url: - return server_url.split(":8080")[0] + ":5000" - elif "/api/stream" in server_url: - return server_url.split("/api/stream")[0].rstrip("/") - - if server_url.endswith("/"): server_url = server_url[:-1] - return server_url + raw = audio_settings.get("stream_server_url", "http://localhost:5000").strip() + + # Ensure scheme is present so urlparse parses host/port correctly + if not raw.startswith(("http://", "https://", "ws://", "wss://")): + raw = "http://" + raw + + parsed = urlparse(raw) + host = parsed.hostname or "localhost" + port = parsed.port or 5000 + + # Remap listener port and common reverse-proxy port to the DJ server port + if port in (5001, 8080): + port = 5000 + + # Always use plain HTTP — the server never terminates TLS directly. + # Any path component (e.g. /stream.mp3, /api/stream) is intentionally stripped. + return f"http://{host}:{port}" def resizeEvent(self, event): """Update glow frame size when window is resized"""