fix: use listener port (no auth) for server library fetch and track downloads

Previously, fetch_server_library() and populate_server_library() used
get_server_base_url() which resolves to the DJ panel port (5000). When
dj_panel_password is set, ALL routes on port 5000 require an authenticated
session cookie. The Qt client had no cookie, so every library.json request
was redirected to /login (HTML), json.loads() failed, and the server list
stayed empty.

Fix:
- Add get_listener_base_url() that derives the listener port (DJ port + 1,
  default 5001) from the configured stream_server_url
- fetch_server_library() now fetches /library.json from the listener port
- Track download URLs built in populate_server_library() now point to the
  listener port's /music_proxy/ route — both are auth-free on the listener
  server (port 5001)
- upload_track() still correctly POSTs to the DJ port (5000) since the
  listener server blocks /upload; it now also authenticates with the DJ
  panel cookie before uploading so uploads work with a password too
This commit is contained in:
ComputerTech 2026-04-03 13:47:47 +01:00
parent 80a4286bc0
commit 46128f5c58
1 changed files with 350 additions and 48 deletions

View File

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