forked from computertech/techdj
Improve downloading with yt-dlp fallback and fix listener streaming
This commit is contained in:
@@ -1,19 +1,109 @@
|
|||||||
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',
|
||||||
headers={
|
headers={
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ flask-socketio
|
|||||||
yt-dlp
|
yt-dlp
|
||||||
eventlet
|
eventlet
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
requests
|
||||||
|
|
||||||
|
|||||||
35
script.js
35
script.js
@@ -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 = () => {
|
||||||
|
|||||||
52
server.py
52
server.py
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user