diff --git a/downloader.py b/downloader.py
index 01ddb5f..72a94bb 100644
--- a/downloader.py
+++ b/downloader.py
@@ -1,18 +1,108 @@
import requests
import os
import re
+import shutil
def clean_filename(title):
# Remove quotes and illegal characters
title = title.strip("'").strip('"')
return re.sub(r'[\\/*?:"<>|]', "", title)
+def _ensure_music_dir():
+ if not os.path.exists("music"):
+ os.makedirs("music")
+
+def _normalize_quality_kbps(quality):
+ try:
+ q = int(str(quality).strip())
+ except Exception:
+ q = 320
+ # Clamp to the expected UI values
+ if q <= 128:
+ return 128
+ if q <= 192:
+ return 192
+ return 320
+
+def _can_use_ytdlp():
+ # yt-dlp needs ffmpeg to reliably output MP3.
+ if shutil.which("ffmpeg") is None:
+ return False
+ try:
+ import yt_dlp # noqa: F401
+ return True
+ except Exception:
+ return False
+
+def _download_with_ytdlp(url, quality_kbps):
+ import yt_dlp
+
+ _ensure_music_dir()
+
+ # Force a predictable output location. yt-dlp will sanitize filenames.
+ outtmpl = os.path.join("music", "%(title)s.%(ext)s")
+
+ ydl_opts = {
+ "format": "bestaudio/best",
+ "outtmpl": outtmpl,
+ "noplaylist": True,
+ "quiet": True,
+ "no_warnings": True,
+ "overwrites": False,
+ "postprocessors": [
+ {
+ "key": "FFmpegExtractAudio",
+ "preferredcodec": "mp3",
+ }
+ ],
+ # Best-effort attempt to honor the UI bitrate selector.
+ # Note: This depends on ffmpeg, and the source may not have enough fidelity.
+ "postprocessor_args": ["-b:a", f"{quality_kbps}k"],
+ }
+
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ info = ydl.extract_info(url, download=True)
+
+ # Handle playlist-like responses defensively even though noplaylist=True.
+ if isinstance(info, dict) and "entries" in info and info["entries"]:
+ info = info["entries"][0]
+
+ # Compute final mp3 path.
+ # prepare_filename returns the pre-postprocessed path, so swap extension.
+ base_path = None
+ if isinstance(info, dict):
+ try:
+ base_path = yt_dlp.YoutubeDL({"outtmpl": outtmpl}).prepare_filename(info)
+ except Exception:
+ base_path = None
+
+ if base_path:
+ mp3_path = os.path.splitext(base_path)[0] + ".mp3"
+ title = os.path.splitext(os.path.basename(mp3_path))[0]
+ return {"success": True, "title": title}
+
+ # Fallback return if we couldn't derive paths
+ return {"success": True, "title": "downloaded"}
+
def download_mp3(url, quality='320'):
print(f"\nš Processing: {url}")
+
+ quality_kbps = _normalize_quality_kbps(quality)
+
+ # Prefer yt-dlp for YouTube because it can actually control MP3 output bitrate.
+ if _can_use_ytdlp():
+ try:
+ print(f"ā¬ļø Downloading via yt-dlp @ {quality_kbps}kbps...")
+ return _download_with_ytdlp(url, quality_kbps)
+ except Exception as e:
+ # If yt-dlp fails for any reason, fall back to the existing Cobalt flow.
+ print(f"ā ļø yt-dlp failed, falling back to Cobalt: {e}")
try:
# Use Cobalt v9 API to download
print("š Requesting download from Cobalt API v9...")
+
+ _ensure_music_dir()
response = requests.post(
'https://api.cobalt.tools/api/v9/process',
@@ -95,8 +185,7 @@ def download_mp3(url, quality='320'):
return {"success": False, "error": str(e)}
if __name__ == "__main__":
- if not os.path.exists("music"):
- os.makedirs("music")
+ _ensure_music_dir()
print("--- TECHDJ DOWNLOADER (via Cobalt API) ---")
while True:
diff --git a/index.html b/index.html
index 852d1be..3b3ca28 100644
--- a/index.html
+++ b/index.html
@@ -375,8 +375,8 @@
diff --git a/requirements.txt b/requirements.txt
index b16028e..ef0a4ae 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,4 +4,5 @@ flask-socketio
yt-dlp
eventlet
python-dotenv
+requests
diff --git a/script.js b/script.js
index 81741be..0648f57 100644
--- a/script.js
+++ b/script.js
@@ -1551,12 +1551,25 @@ function initSocket() {
if (socket) return socket;
// Log connection details
- const serverUrl = window.location.origin;
+ const urlParams = new URLSearchParams(window.location.search);
+ const isListenerMode =
+ window.location.port === '5001' ||
+ window.location.hostname.startsWith('music.') ||
+ window.location.hostname.startsWith('listen.') ||
+ urlParams.get('listen') === 'true';
+
+ // If someone opens listener mode on the DJ port (e.g. :5000?listen=true),
+ // force the Socket.IO connection to the listener backend (:5001).
+ const serverUrl = (isListenerMode && window.location.port !== '5001' &&
+ !window.location.hostname.startsWith('music.') &&
+ !window.location.hostname.startsWith('listen.'))
+ ? `${window.location.protocol}//${window.location.hostname}:5001`
+ : window.location.origin;
console.log(`š Initializing Socket.IO connection to: ${serverUrl}`);
console.log(` Protocol: ${window.location.protocol}`);
console.log(` Host: ${window.location.host}`);
- socket = io({
+ socket = io(serverUrl, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: 10
@@ -2025,14 +2038,14 @@ function restartBroadcast() {
}
// Copy stream URL to clipboard
-function copyStreamUrl() {
+function copyStreamUrl(evt) {
const urlInput = document.getElementById('stream-url');
urlInput.select();
urlInput.setSelectionRange(0, 99999); // For mobile
try {
document.execCommand('copy');
- const btn = event.target;
+ const btn = evt?.target;
const originalText = btn.textContent;
btn.textContent = 'ā';
setTimeout(() => {
@@ -2142,10 +2155,6 @@ function initListenerMode() {
sourceBuffer = mediaSource.addSourceBuffer('audio/webm;codecs=opus');
sourceBuffer.mode = 'sequence';
- // Request the startup header now that we are ready to receive it
- console.log('š” Requesting stream header...');
- socket.emit('request_header');
-
// Kick off first append if data is already in queue
if (audioQueue.length > 0 && !sourceBuffer.updating && mediaSource.readyState === 'open') {
sourceBuffer.appendBuffer(audioQueue.shift());
@@ -2229,9 +2238,6 @@ function initListenerMode() {
audioQueue = [];
chunksReceived = 0;
window.sourceBufferErrorCount = 0;
- setTimeout(() => {
- if (socket) socket.emit('request_header');
- }, 1000);
}
}
}
@@ -2317,7 +2323,12 @@ async function enableListenerAudio() {
// Unmute just in case
window.listenerAudio.muted = false;
- window.listenerAudio.volume = settings.volume || 0.8;
+ // Volume is controlled via listenerGainNode; keep element volume sane.
+ window.listenerAudio.volume = 1.0;
+
+ const volEl = document.getElementById('listener-volume');
+ const volValue = volEl ? parseInt(volEl.value, 10) : 80;
+ setListenerVolume(Number.isFinite(volValue) ? volValue : 80);
// Check if we have buffered data
const hasBufferedData = () => {
diff --git a/server.py b/server.py
index 12be00e..0575640 100644
--- a/server.py
+++ b/server.py
@@ -14,7 +14,8 @@ import downloader
broadcast_state = {
'active': False
}
-listener_count = 0
+listener_sids = set()
+dj_sids = set()
MUSIC_FOLDER = "music"
# Ensure music folder exists
if not os.path.exists(MUSIC_FOLDER):
@@ -36,7 +37,7 @@ def setup_shared_routes(app):
@app.route('/download', methods=['POST'])
def download():
- data = request.json
+ data = request.get_json(silent=True) or {}
url = data.get('url')
quality = data.get('quality', '320')
if not url:
@@ -155,15 +156,12 @@ dj_socketio = SocketIO(
@dj_socketio.on('connect')
def dj_connect():
print(f"š§ DJ connected: {request.sid}")
- session['is_dj'] = True
+ dj_sids.add(request.sid)
@dj_socketio.on('disconnect')
def dj_disconnect():
- if session.get('is_dj'):
- print("ā ļø DJ disconnected - broadcast will continue until manually stopped")
- session['is_dj'] = False
- # Don't stop streaming_active - let it continue
- # Broadcast will resume when DJ reconnects
+ dj_sids.discard(request.sid)
+ print("ā ļø DJ disconnected - broadcast will continue until manually stopped")
def stop_broadcast_after_timeout():
"""No longer used - broadcasts don't auto-stop"""
@@ -193,7 +191,7 @@ def dj_audio(data):
if broadcast_state['active']:
listener_socketio.emit('audio_data', data, namespace='/')
-# === LISTENER SERVER (Port 6000) ===
+# === LISTENER SERVER (Port 5001) ===
listener_app = Flask(__name__, static_folder='.', static_url_path='')
listener_app.config['SECRET_KEY'] = 'listener_secret'
setup_shared_routes(listener_app)
@@ -214,39 +212,27 @@ def listener_connect():
@listener_socketio.on('disconnect')
def listener_disconnect():
- global listener_count
- if session.get('is_listener'):
- # Clear session flag FIRST to prevent re-entry issues
- session['is_listener'] = False
- listener_count = max(0, listener_count - 1)
- print(f"ā Listener left. Total: {listener_count}")
- # Broadcast to all listeners
- listener_socketio.emit('listener_count', {'count': listener_count}, namespace='/')
- # Broadcast to all DJs
- dj_socketio.emit('listener_count', {'count': listener_count}, namespace='/')
+ if request.sid in listener_sids:
+ listener_sids.discard(request.sid)
+ count = len(listener_sids)
+ print(f"ā Listener left. Total: {count}")
+ listener_socketio.emit('listener_count', {'count': count}, namespace='/')
+ dj_socketio.emit('listener_count', {'count': count}, namespace='/')
@listener_socketio.on('join_listener')
def listener_join():
- global listener_count
- if not session.get('is_listener'):
- session['is_listener'] = True
- listener_count += 1
- print(f"š New listener joined. Total: {listener_count}")
- # Broadcast to all listeners
- listener_socketio.emit('listener_count', {'count': listener_count}, namespace='/')
- # Broadcast to all DJs
- dj_socketio.emit('listener_count', {'count': listener_count}, namespace='/')
+ if request.sid not in listener_sids:
+ listener_sids.add(request.sid)
+ count = len(listener_sids)
+ print(f"š New listener joined. Total: {count}")
+ listener_socketio.emit('listener_count', {'count': count}, namespace='/')
+ dj_socketio.emit('listener_count', {'count': count}, namespace='/')
emit('stream_status', {'active': broadcast_state['active']})
-@listener_socketio.on('request_header')
-def handle_request_header():
- # Header logic removed for local relay mode
- pass
-
@listener_socketio.on('get_listener_count')
def listener_get_count():
- emit('listener_count', {'count': listener_count})
+ emit('listener_count', {'count': len(listener_sids)})
# DJ Panel Routes (No engine commands needed in local mode)
@dj_socketio.on('get_mixer_status')