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:
parent
80a4286bc0
commit
46128f5c58
366
techdj_qt.py
366
techdj_qt.py
|
|
@ -9,6 +9,7 @@ import shutil
|
||||||
import requests
|
import requests
|
||||||
import re
|
import re
|
||||||
import socketio
|
import socketio
|
||||||
|
from urllib.parse import urlparse
|
||||||
import subprocess
|
import subprocess
|
||||||
import atexit
|
import atexit
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -250,6 +251,50 @@ def get_audio_capture_source():
|
||||||
return 'default.monitor'
|
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)
|
atexit.register(_teardown_audio_isolation)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -583,8 +628,26 @@ class SettingsDialog(QDialog):
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
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(stream_url_label)
|
||||||
layout.addWidget(self.stream_url_input)
|
layout.addWidget(self.stream_url_input)
|
||||||
|
layout.addSpacing(8)
|
||||||
|
layout.addWidget(pw_label)
|
||||||
|
layout.addWidget(self.dj_password_input)
|
||||||
layout.addSpacing(20)
|
layout.addSpacing(20)
|
||||||
|
|
||||||
# Recording section
|
# Recording section
|
||||||
|
|
@ -684,6 +747,7 @@ class SettingsDialog(QDialog):
|
||||||
"recording_sample_rate": self.sample_rate_combo.currentData(),
|
"recording_sample_rate": self.sample_rate_combo.currentData(),
|
||||||
"recording_format": self.format_combo.currentData(),
|
"recording_format": self.format_combo.currentData(),
|
||||||
"stream_server_url": self.stream_url_input.text(),
|
"stream_server_url": self.stream_url_input.text(),
|
||||||
|
"dj_panel_password": self.dj_password_input.text(),
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"neon_mode": self.neon_combo.currentData(),
|
"neon_mode": self.neon_combo.currentData(),
|
||||||
|
|
@ -810,6 +874,8 @@ class StreamingWorker(QThread):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.sio = None
|
self.sio = None
|
||||||
self.stream_url = ""
|
self.stream_url = ""
|
||||||
|
self.dj_password = ""
|
||||||
|
self.glow_intensity = 30 # mirrors the UI slider; sent to listeners on connect
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.ffmpeg_proc = None
|
self.ffmpeg_proc = None
|
||||||
self._broadcast_started = False
|
self._broadcast_started = False
|
||||||
|
|
@ -819,11 +885,14 @@ class StreamingWorker(QThread):
|
||||||
if not self._broadcast_started:
|
if not self._broadcast_started:
|
||||||
self._broadcast_started = True
|
self._broadcast_started = True
|
||||||
self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'})
|
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()
|
self.streaming_started.emit()
|
||||||
else:
|
else:
|
||||||
# Reconnected mid-stream - server handles resume gracefully
|
# Reconnected mid-stream — server handles resume gracefully
|
||||||
print("[SOCKET] Reconnected - resuming existing broadcast")
|
print("[SOCKET] Reconnected - resuming existing broadcast")
|
||||||
self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'})
|
self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'})
|
||||||
|
self.sio.emit('listener_glow', {'intensity': self.glow_intensity})
|
||||||
|
|
||||||
def on_disconnect(self):
|
def on_disconnect(self):
|
||||||
print("[SOCKET] Disconnected from DJ server")
|
print("[SOCKET] Disconnected from DJ server")
|
||||||
|
|
@ -834,6 +903,42 @@ class StreamingWorker(QThread):
|
||||||
def on_listener_count(self, data):
|
def on_listener_count(self, data):
|
||||||
self.listener_count.emit(data.get('count', 0))
|
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):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
# Create a fresh Socket.IO client for each session
|
# Create a fresh Socket.IO client for each session
|
||||||
|
|
@ -843,7 +948,25 @@ class StreamingWorker(QThread):
|
||||||
self.sio.on('listener_count', self.on_listener_count)
|
self.sio.on('listener_count', self.on_listener_count)
|
||||||
self.sio.on('connect_error', self.on_connect_error)
|
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()
|
source = get_audio_capture_source()
|
||||||
print(f"[STREAM] Capturing from: {source}")
|
print(f"[STREAM] Capturing from: {source}")
|
||||||
|
|
@ -863,46 +986,83 @@ class StreamingWorker(QThread):
|
||||||
# Flush every packet — critical for low-latency pipe streaming
|
# Flush every packet — critical for low-latency pipe streaming
|
||||||
"-flush_packets", "1",
|
"-flush_packets", "1",
|
||||||
"-f", "mp3",
|
"-f", "mp3",
|
||||||
"pipe:1"
|
"pipe:1",
|
||||||
]
|
]
|
||||||
self.ffmpeg_proc = subprocess.Popen(
|
self.ffmpeg_proc = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
|
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:
|
while self.is_running and self.ffmpeg_proc.poll() is None:
|
||||||
# 4096 bytes ≈ 10 MP3 frames ≈ ~260 ms at 128 kbps — low-latency chunks
|
# 4096 bytes ≈ 10 MP3 frames ≈ ~260 ms at 128 kbps — low-latency chunks
|
||||||
chunk = self.ffmpeg_proc.stdout.read(4096)
|
chunk = self.ffmpeg_proc.stdout.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
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:
|
if sio and sio.connected:
|
||||||
sio.emit('audio_chunk', chunk)
|
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:
|
except Exception as e:
|
||||||
self.streaming_error.emit(f"Streaming thread error: {e}")
|
self.streaming_error.emit(f"Streaming error: {e}")
|
||||||
finally:
|
finally:
|
||||||
self.stop_streaming()
|
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.stream_url = base_url
|
||||||
|
self.dj_password = password
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
self.start()
|
self.start()
|
||||||
return True
|
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):
|
def stop_streaming(self):
|
||||||
|
"""Thread-safe stop: capture refs locally before clearing to avoid TOCTOU."""
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self._broadcast_started = False
|
self._broadcast_started = False
|
||||||
if self.ffmpeg_proc:
|
|
||||||
try: self.ffmpeg_proc.terminate()
|
proc = self.ffmpeg_proc
|
||||||
except: pass
|
|
||||||
self.ffmpeg_proc = None
|
self.ffmpeg_proc = None
|
||||||
if self.sio and self.sio.connected:
|
sio = self.sio
|
||||||
|
self.sio = None
|
||||||
|
|
||||||
|
if proc:
|
||||||
try:
|
try:
|
||||||
self.sio.emit('stop_broadcast')
|
proc.terminate()
|
||||||
self.sio.disconnect()
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if sio:
|
||||||
|
try:
|
||||||
|
if sio.connected:
|
||||||
|
sio.emit('stop_broadcast')
|
||||||
|
sio.disconnect()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.sio = None
|
|
||||||
|
|
||||||
# --- WIDGETS ---
|
# --- WIDGETS ---
|
||||||
|
|
||||||
|
|
@ -1144,6 +1304,7 @@ class DeckWidget(QGroupBox):
|
||||||
self.loop_end = 0
|
self.loop_end = 0
|
||||||
self.loop_btns = []
|
self.loop_btns = []
|
||||||
self.xf_vol = 100
|
self.xf_vol = 100
|
||||||
|
self.current_title = ""
|
||||||
|
|
||||||
self.loop_timer = QTimer(self)
|
self.loop_timer = QTimer(self)
|
||||||
self.loop_timer.setInterval(LOOP_CHECK_INTERVAL)
|
self.loop_timer.setInterval(LOOP_CHECK_INTERVAL)
|
||||||
|
|
@ -1379,6 +1540,7 @@ class DeckWidget(QGroupBox):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.player.setSource(QUrl.fromLocalFile(str(p.absolute())))
|
self.player.setSource(QUrl.fromLocalFile(str(p.absolute())))
|
||||||
|
self.current_title = p.stem
|
||||||
self.lbl_tr.setText(p.stem.upper())
|
self.lbl_tr.setText(p.stem.upper())
|
||||||
self.vinyl.set_speed(0)
|
self.vinyl.set_speed(0)
|
||||||
self.vinyl.angle = 0
|
self.vinyl.angle = 0
|
||||||
|
|
@ -1428,13 +1590,31 @@ class DeckWidget(QGroupBox):
|
||||||
# Stop mode or no queue items
|
# Stop mode or no queue items
|
||||||
self.stop()
|
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):
|
def play(self):
|
||||||
self.player.play()
|
self.player.play()
|
||||||
self.vinyl.start_spin()
|
self.vinyl.start_spin()
|
||||||
|
self._emit_playing_state(True)
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
self.player.pause()
|
self.player.pause()
|
||||||
self.vinyl.stop_spin()
|
self.vinyl.stop_spin()
|
||||||
|
self._emit_playing_state(False)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.player.stop()
|
self.player.stop()
|
||||||
|
|
@ -1442,6 +1622,7 @@ class DeckWidget(QGroupBox):
|
||||||
self.vinyl.angle = 0
|
self.vinyl.angle = 0
|
||||||
self.vinyl.update()
|
self.vinyl.update()
|
||||||
self.clear_loop()
|
self.clear_loop()
|
||||||
|
self._emit_playing_state(False)
|
||||||
|
|
||||||
def on_position_changed(self, pos):
|
def on_position_changed(self, pos):
|
||||||
self.wave.set_position(pos)
|
self.wave.set_position(pos)
|
||||||
|
|
@ -1532,6 +1713,15 @@ class DJApp(QMainWindow):
|
||||||
self.streaming_worker.listener_count.connect(self.update_listener_count)
|
self.streaming_worker.listener_count.connect(self.update_listener_count)
|
||||||
self.is_streaming = False
|
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
|
# Server library state
|
||||||
self.server_url = "http://localhost:5000"
|
self.server_url = "http://localhost:5000"
|
||||||
self.library_mode = "local" # "local" or "server"
|
self.library_mode = "local" # "local" or "server"
|
||||||
|
|
@ -1726,6 +1916,56 @@ class DJApp(QMainWindow):
|
||||||
stream_info.addWidget(self.stream_status_label)
|
stream_info.addWidget(self.stream_status_label)
|
||||||
stream_info.addWidget(self.listener_count_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.addStretch()
|
||||||
rec_layout.addWidget(self.record_button)
|
rec_layout.addWidget(self.record_button)
|
||||||
rec_layout.addSpacing(20)
|
rec_layout.addSpacing(20)
|
||||||
|
|
@ -1734,6 +1974,8 @@ class DJApp(QMainWindow):
|
||||||
rec_layout.addWidget(self.stream_button)
|
rec_layout.addWidget(self.stream_button)
|
||||||
rec_layout.addSpacing(20)
|
rec_layout.addSpacing(20)
|
||||||
rec_layout.addLayout(stream_info)
|
rec_layout.addLayout(stream_info)
|
||||||
|
rec_layout.addSpacing(40)
|
||||||
|
rec_layout.addLayout(glow_vbox)
|
||||||
rec_layout.addStretch()
|
rec_layout.addStretch()
|
||||||
|
|
||||||
layout.addLayout(rec_layout, 3)
|
layout.addLayout(rec_layout, 3)
|
||||||
|
|
@ -2001,11 +2243,15 @@ class DJApp(QMainWindow):
|
||||||
self.library_list.clear()
|
self.library_list.clear()
|
||||||
self.library_list.addItem("Fetching server library...")
|
self.library_list.addItem("Fetching server library...")
|
||||||
|
|
||||||
base_url = self.get_server_base_url()
|
# Use the listener port (no auth required) for library / track data.
|
||||||
self.server_url = base_url
|
# 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"{base_url}/library.json")
|
self.fetcher = ServerLibraryFetcher(f"{listener_url}/library.json")
|
||||||
self.fetcher.finished.connect(lambda tracks, err, success: self.on_server_library_fetched(tracks, base_url, err, success))
|
self.fetcher.finished.connect(
|
||||||
|
lambda tracks, err, success: self.on_server_library_fetched(tracks, listener_url, err, success)
|
||||||
|
)
|
||||||
self.fetcher.start()
|
self.fetcher.start()
|
||||||
|
|
||||||
def on_server_library_fetched(self, tracks, base_url, err, success):
|
def on_server_library_fetched(self, tracks, base_url, err, success):
|
||||||
|
|
@ -2136,10 +2382,24 @@ class DJApp(QMainWindow):
|
||||||
try:
|
try:
|
||||||
self.status_label.setText("Uploading to server...")
|
self.status_label.setText("Uploading to server...")
|
||||||
base_url = self.get_server_base_url()
|
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:
|
with open(file_path, 'rb') as f:
|
||||||
files = {'file': 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:
|
if response.status_code == 200:
|
||||||
self.status_label.setText("Upload successful!")
|
self.status_label.setText("Upload successful!")
|
||||||
|
|
@ -2255,12 +2515,13 @@ class DJApp(QMainWindow):
|
||||||
def toggle_streaming(self):
|
def toggle_streaming(self):
|
||||||
"""Toggle live streaming on/off"""
|
"""Toggle live streaming on/off"""
|
||||||
if not self.is_streaming:
|
if not self.is_streaming:
|
||||||
# Get base URL from settings
|
# Get base URL and credentials from settings
|
||||||
base_url = self.get_server_base_url()
|
base_url = self.get_server_base_url()
|
||||||
bitrate = self.all_settings.get("audio", {}).get("bitrate", 128)
|
bitrate = self.all_settings.get("audio", {}).get("bitrate", 128)
|
||||||
|
password = self.all_settings.get("audio", {}).get("dj_panel_password", "")
|
||||||
|
|
||||||
# Start streaming
|
# Start streaming (password handled inside StreamingWorker)
|
||||||
if self.streaming_worker.start_streaming(base_url, bitrate):
|
if self.streaming_worker.start_streaming(base_url, bitrate, password=password):
|
||||||
self.is_streaming = True
|
self.is_streaming = True
|
||||||
self.stream_button.setText("STOP")
|
self.stream_button.setText("STOP")
|
||||||
self.stream_button.setStyleSheet("""
|
self.stream_button.setStyleSheet("""
|
||||||
|
|
@ -2315,21 +2576,62 @@ class DJApp(QMainWindow):
|
||||||
def update_listener_count(self, count):
|
def update_listener_count(self, count):
|
||||||
self.listener_count_label.setText(f"{count} listeners")
|
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):
|
def get_server_base_url(self):
|
||||||
audio_settings = self.all_settings.get("audio", {})
|
audio_settings = self.all_settings.get("audio", {})
|
||||||
server_url = audio_settings.get("stream_server_url", "http://localhost:5000")
|
raw = audio_settings.get("stream_server_url", "http://localhost:5000").strip()
|
||||||
|
|
||||||
# Normal techdj server runs on 5000 (DJ) and 5001 (Listener)
|
# Ensure scheme is present so urlparse parses host/port correctly
|
||||||
# If the URL is for the listener or stream, switch to 5000
|
if not raw.startswith(("http://", "https://", "ws://", "wss://")):
|
||||||
if ":5001" in server_url:
|
raw = "http://" + raw
|
||||||
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]
|
parsed = urlparse(raw)
|
||||||
return server_url
|
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):
|
def resizeEvent(self, event):
|
||||||
"""Update glow frame size when window is resized"""
|
"""Update glow frame size when window is resized"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue