Improve downloading with yt-dlp fallback and fix listener streaming

This commit is contained in:
3nd3r
2026-01-02 21:20:32 -06:00
parent 5026d39280
commit e8163fb9a2
5 changed files with 137 additions and 50 deletions

View File

@@ -1,18 +1,108 @@
import requests import requests
import os import os
import re import re
import shutil
def clean_filename(title): def clean_filename(title):
# Remove quotes and illegal characters # Remove quotes and illegal characters
title = title.strip("'").strip('"') title = title.strip("'").strip('"')
return re.sub(r'[\\/*?:"<>|]', "", title) 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'): def download_mp3(url, quality='320'):
print(f"\n🔍 Processing: {url}") 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: try:
# Use Cobalt v9 API to download # Use Cobalt v9 API to download
print("🌐 Requesting download from Cobalt API v9...") print("🌐 Requesting download from Cobalt API v9...")
_ensure_music_dir()
response = requests.post( response = requests.post(
'https://api.cobalt.tools/api/v9/process', 'https://api.cobalt.tools/api/v9/process',
@@ -95,8 +185,7 @@ def download_mp3(url, quality='320'):
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
if __name__ == "__main__": if __name__ == "__main__":
if not os.path.exists("music"): _ensure_music_dir()
os.makedirs("music")
print("--- TECHDJ DOWNLOADER (via Cobalt API) ---") print("--- TECHDJ DOWNLOADER (via Cobalt API) ---")
while True: while True:

View File

@@ -375,8 +375,8 @@
<div class="stream-url-section"> <div class="stream-url-section">
<label>Share this URL:</label> <label>Share this URL:</label>
<div class="url-copy-group"> <div class="url-copy-group">
<input type="text" id="stream-url" readonly value="http://localhost:5000?listen=true"> <input type="text" id="stream-url" readonly value="http://localhost:5001">
<button onclick="copyStreamUrl()" class="copy-btn">📋</button> <button onclick="copyStreamUrl(event)" class="copy-btn">📋</button>
</div> </div>
</div> </div>

View File

@@ -4,4 +4,5 @@ flask-socketio
yt-dlp yt-dlp
eventlet eventlet
python-dotenv python-dotenv
requests

View File

@@ -1551,12 +1551,25 @@ function initSocket() {
if (socket) return socket; if (socket) return socket;
// Log connection details // 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(`🔌 Initializing Socket.IO connection to: ${serverUrl}`);
console.log(` Protocol: ${window.location.protocol}`); console.log(` Protocol: ${window.location.protocol}`);
console.log(` Host: ${window.location.host}`); console.log(` Host: ${window.location.host}`);
socket = io({ socket = io(serverUrl, {
transports: ['websocket'], transports: ['websocket'],
reconnection: true, reconnection: true,
reconnectionAttempts: 10 reconnectionAttempts: 10
@@ -2025,14 +2038,14 @@ function restartBroadcast() {
} }
// Copy stream URL to clipboard // Copy stream URL to clipboard
function copyStreamUrl() { function copyStreamUrl(evt) {
const urlInput = document.getElementById('stream-url'); const urlInput = document.getElementById('stream-url');
urlInput.select(); urlInput.select();
urlInput.setSelectionRange(0, 99999); // For mobile urlInput.setSelectionRange(0, 99999); // For mobile
try { try {
document.execCommand('copy'); document.execCommand('copy');
const btn = event.target; const btn = evt?.target;
const originalText = btn.textContent; const originalText = btn.textContent;
btn.textContent = '✓'; btn.textContent = '✓';
setTimeout(() => { setTimeout(() => {
@@ -2142,10 +2155,6 @@ function initListenerMode() {
sourceBuffer = mediaSource.addSourceBuffer('audio/webm;codecs=opus'); sourceBuffer = mediaSource.addSourceBuffer('audio/webm;codecs=opus');
sourceBuffer.mode = 'sequence'; 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 // Kick off first append if data is already in queue
if (audioQueue.length > 0 && !sourceBuffer.updating && mediaSource.readyState === 'open') { if (audioQueue.length > 0 && !sourceBuffer.updating && mediaSource.readyState === 'open') {
sourceBuffer.appendBuffer(audioQueue.shift()); sourceBuffer.appendBuffer(audioQueue.shift());
@@ -2229,9 +2238,6 @@ function initListenerMode() {
audioQueue = []; audioQueue = [];
chunksReceived = 0; chunksReceived = 0;
window.sourceBufferErrorCount = 0; window.sourceBufferErrorCount = 0;
setTimeout(() => {
if (socket) socket.emit('request_header');
}, 1000);
} }
} }
} }
@@ -2317,7 +2323,12 @@ async function enableListenerAudio() {
// Unmute just in case // Unmute just in case
window.listenerAudio.muted = false; 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 // Check if we have buffered data
const hasBufferedData = () => { const hasBufferedData = () => {

View File

@@ -14,7 +14,8 @@ import downloader
broadcast_state = { broadcast_state = {
'active': False 'active': False
} }
listener_count = 0 listener_sids = set()
dj_sids = set()
MUSIC_FOLDER = "music" MUSIC_FOLDER = "music"
# Ensure music folder exists # Ensure music folder exists
if not os.path.exists(MUSIC_FOLDER): if not os.path.exists(MUSIC_FOLDER):
@@ -36,7 +37,7 @@ def setup_shared_routes(app):
@app.route('/download', methods=['POST']) @app.route('/download', methods=['POST'])
def download(): def download():
data = request.json data = request.get_json(silent=True) or {}
url = data.get('url') url = data.get('url')
quality = data.get('quality', '320') quality = data.get('quality', '320')
if not url: if not url:
@@ -155,15 +156,12 @@ dj_socketio = SocketIO(
@dj_socketio.on('connect') @dj_socketio.on('connect')
def dj_connect(): def dj_connect():
print(f"🎧 DJ connected: {request.sid}") print(f"🎧 DJ connected: {request.sid}")
session['is_dj'] = True dj_sids.add(request.sid)
@dj_socketio.on('disconnect') @dj_socketio.on('disconnect')
def dj_disconnect(): def dj_disconnect():
if session.get('is_dj'): dj_sids.discard(request.sid)
print("⚠️ DJ disconnected - broadcast will continue until manually stopped") 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
def stop_broadcast_after_timeout(): def stop_broadcast_after_timeout():
"""No longer used - broadcasts don't auto-stop""" """No longer used - broadcasts don't auto-stop"""
@@ -193,7 +191,7 @@ def dj_audio(data):
if broadcast_state['active']: if broadcast_state['active']:
listener_socketio.emit('audio_data', data, namespace='/') 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 = Flask(__name__, static_folder='.', static_url_path='')
listener_app.config['SECRET_KEY'] = 'listener_secret' listener_app.config['SECRET_KEY'] = 'listener_secret'
setup_shared_routes(listener_app) setup_shared_routes(listener_app)
@@ -214,39 +212,27 @@ def listener_connect():
@listener_socketio.on('disconnect') @listener_socketio.on('disconnect')
def listener_disconnect(): def listener_disconnect():
global listener_count if request.sid in listener_sids:
if session.get('is_listener'): listener_sids.discard(request.sid)
# Clear session flag FIRST to prevent re-entry issues count = len(listener_sids)
session['is_listener'] = False print(f"❌ Listener left. Total: {count}")
listener_count = max(0, listener_count - 1) listener_socketio.emit('listener_count', {'count': count}, namespace='/')
print(f"❌ Listener left. Total: {listener_count}") dj_socketio.emit('listener_count', {'count': count}, namespace='/')
# 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='/')
@listener_socketio.on('join_listener') @listener_socketio.on('join_listener')
def listener_join(): def listener_join():
global listener_count if request.sid not in listener_sids:
if not session.get('is_listener'): listener_sids.add(request.sid)
session['is_listener'] = True count = len(listener_sids)
listener_count += 1 print(f"👂 New listener joined. Total: {count}")
print(f"👂 New listener joined. Total: {listener_count}") listener_socketio.emit('listener_count', {'count': count}, namespace='/')
# Broadcast to all listeners dj_socketio.emit('listener_count', {'count': count}, namespace='/')
listener_socketio.emit('listener_count', {'count': listener_count}, namespace='/')
# Broadcast to all DJs
dj_socketio.emit('listener_count', {'count': listener_count}, namespace='/')
emit('stream_status', {'active': broadcast_state['active']}) 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') @listener_socketio.on('get_listener_count')
def listener_get_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 Panel Routes (No engine commands needed in local mode)
@dj_socketio.on('get_mixer_status') @dj_socketio.on('get_mixer_status')