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')