Optimize Stream: YouTube removal, Latency improvements, Hotel Wi-Fi kit, and Listener sync
This commit is contained in:
parent
8ab422a7aa
commit
508b93125d
210
downloader.py
210
downloader.py
|
|
@ -1,210 +0,0 @@
|
|||
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"✨ Using yt-dlp (preferred method)")
|
||||
print(f"⬇️ Downloading @ {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 API: {e}")
|
||||
else:
|
||||
# Check what's missing
|
||||
has_ffmpeg = shutil.which("ffmpeg") is not None
|
||||
has_ytdlp = False
|
||||
try:
|
||||
import yt_dlp # noqa: F401
|
||||
has_ytdlp = True
|
||||
except:
|
||||
pass
|
||||
|
||||
if not has_ffmpeg:
|
||||
print("⚠️ ffmpeg not found - using Cobalt API fallback")
|
||||
if not has_ytdlp:
|
||||
print("⚠️ yt-dlp not installed - using Cobalt API fallback")
|
||||
print(" 💡 Install with: pip install yt-dlp")
|
||||
|
||||
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',
|
||||
headers={
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
json={
|
||||
'url': url,
|
||||
'downloadMode': 'audio',
|
||||
'audioFormat': 'mp3'
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
print(f"📡 API Response Status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
error_data = response.json()
|
||||
print(f"❌ Cobalt API error: {error_data}")
|
||||
except:
|
||||
print(f"❌ Cobalt API error: {response.text}")
|
||||
return {"success": False, "error": f"API returned {response.status_code}"}
|
||||
|
||||
data = response.json()
|
||||
print(f"📦 API Response: {data}")
|
||||
|
||||
# Check for errors in response
|
||||
if data.get('status') == 'error':
|
||||
error_msg = data.get('text', 'Unknown error')
|
||||
print(f"❌ Cobalt error: {error_msg}")
|
||||
return {"success": False, "error": error_msg}
|
||||
|
||||
# Get download URL
|
||||
download_url = data.get('url')
|
||||
if not download_url:
|
||||
print(f"❌ No download URL in response: {data}")
|
||||
return {"success": False, "error": "No download URL received"}
|
||||
|
||||
print(f"📥 Downloading audio...")
|
||||
|
||||
# Download the audio file
|
||||
audio_response = requests.get(download_url, stream=True, timeout=60)
|
||||
|
||||
if audio_response.status_code != 200:
|
||||
print(f"❌ Download failed: {audio_response.status_code}")
|
||||
return {"success": False, "error": f"Download failed with status {audio_response.status_code}"}
|
||||
|
||||
# Try to get filename from Content-Disposition header
|
||||
content_disposition = audio_response.headers.get('Content-Disposition', '')
|
||||
if 'filename=' in content_disposition:
|
||||
filename = content_disposition.split('filename=')[1].strip('"')
|
||||
filename = clean_filename(os.path.splitext(filename)[0])
|
||||
else:
|
||||
# Fallback: extract video ID and use it
|
||||
video_id = url.split('v=')[-1].split('&')[0]
|
||||
filename = f"youtube_{video_id}"
|
||||
|
||||
# Ensure .mp3 extension
|
||||
output_path = f"music/{filename}.mp3"
|
||||
|
||||
# Save the file
|
||||
with open(output_path, 'wb') as f:
|
||||
for chunk in audio_response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
print(f"✅ Success! Saved as: {filename}.mp3")
|
||||
print(" (Hit Refresh in the App)")
|
||||
return {"success": True, "title": filename}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print("❌ Request timed out")
|
||||
return {"success": False, "error": "Request timed out"}
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ Network error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
if __name__ == "__main__":
|
||||
_ensure_music_dir()
|
||||
|
||||
print("--- TECHDJ DOWNLOADER (via Cobalt API) ---")
|
||||
while True:
|
||||
url = input("\n🔗 URL (q to quit): ").strip()
|
||||
if url.lower() == 'q': break
|
||||
if url: download_mp3(url)
|
||||
30
index.html
30
index.html
|
|
@ -50,12 +50,6 @@
|
|||
<button class="refresh-btn" onclick="refreshLibrary()" title="Refresh Library">🔄</button>
|
||||
</div>
|
||||
|
||||
<!-- YouTube Search -->
|
||||
<div class="youtube-search-container">
|
||||
<input type="text" id="youtube-search" placeholder="🎵 SEARCH YOUTUBE..."
|
||||
onkeyup="handleYouTubeSearch(this.value)">
|
||||
<div id="youtube-results" class="youtube-results"></div>
|
||||
</div>
|
||||
|
||||
<div id="library-list" class="library-list">
|
||||
<!-- Tracks go here -->
|
||||
|
|
@ -122,18 +116,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="start-group">
|
||||
<input type="text" class="search-input" id="search-input-A" placeholder="PASTE YOUTUBE URL...">
|
||||
<div class="download-controls">
|
||||
<select id="quality-A" class="quality-selector">
|
||||
<option value="128">128kbps</option>
|
||||
<option value="192">192kbps</option>
|
||||
<option value="320" selected>320kbps</option>
|
||||
</select>
|
||||
<button class="download-btn" onclick="downloadFromPanel('A')">⬇ DOWNLOAD</button>
|
||||
</div>
|
||||
<div id="download-status-A" class="download-status"></div>
|
||||
</div>
|
||||
|
||||
<div class="controls-grid">
|
||||
<!-- Volume Fader -->
|
||||
|
|
@ -260,18 +242,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="start-group">
|
||||
<input type="text" class="search-input" id="search-input-B" placeholder="PASTE YOUTUBE URL...">
|
||||
<div class="download-controls">
|
||||
<select id="quality-B" class="quality-selector">
|
||||
<option value="128">128kbps</option>
|
||||
<option value="192">192kbps</option>
|
||||
<option value="320" selected>320kbps</option>
|
||||
</select>
|
||||
<button class="download-btn" onclick="downloadFromPanel('B')">⬇ DOWNLOAD</button>
|
||||
</div>
|
||||
<div id="download-status-B" class="download-status"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="controls-grid">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
# TechDJ Requirements
|
||||
flask
|
||||
flask-socketio
|
||||
yt-dlp
|
||||
eventlet
|
||||
python-dotenv
|
||||
requests
|
||||
|
||||
|
|
|
|||
158
script.js
158
script.js
|
|
@ -1156,107 +1156,6 @@ function refreshLibrary() {
|
|||
fetchLibrary();
|
||||
}
|
||||
|
||||
// YouTube Search Functions
|
||||
let youtubeSearchTimeout = null;
|
||||
|
||||
function handleYouTubeSearch(query) {
|
||||
// Debounce search - wait 300ms after user stops typing
|
||||
clearTimeout(youtubeSearchTimeout);
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
document.getElementById('youtube-results').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
youtubeSearchTimeout = setTimeout(() => {
|
||||
searchYouTube(query.trim());
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function searchYouTube(query) {
|
||||
const resultsDiv = document.getElementById('youtube-results');
|
||||
resultsDiv.innerHTML = '<div class="search-loading">🔍 Searching YouTube...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/search_youtube?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
resultsDiv.innerHTML = `<div class="search-error">❌ ${data.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
displayYouTubeResults(data.results);
|
||||
} catch (error) {
|
||||
console.error('YouTube search error:', error);
|
||||
resultsDiv.innerHTML = '<div class="search-error">❌ Search failed. Check console.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function displayYouTubeResults(results) {
|
||||
const resultsDiv = document.getElementById('youtube-results');
|
||||
|
||||
if (results.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="search-empty">No results found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
results.forEach(result => {
|
||||
const resultCard = document.createElement('div');
|
||||
resultCard.className = 'youtube-result-card';
|
||||
|
||||
resultCard.innerHTML = `
|
||||
<img src="${result.thumbnail}" alt="${result.title}" class="result-thumbnail">
|
||||
<div class="result-info">
|
||||
<div class="result-title">${result.title}</div>
|
||||
<div class="result-channel">${result.channel}</div>
|
||||
</div>
|
||||
<button class="result-download-btn" onclick="downloadYouTubeResult('${result.url}', '${result.title.replace(/'/g, "\\'")}')">
|
||||
⬇️
|
||||
</button>
|
||||
`;
|
||||
|
||||
resultsDiv.appendChild(resultCard);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadYouTubeResult(url, title) {
|
||||
const resultsDiv = document.getElementById('youtube-results');
|
||||
const originalContent = resultsDiv.innerHTML;
|
||||
|
||||
resultsDiv.innerHTML = '<div class="search-loading">⏳ Downloading...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url, quality: '320' })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
resultsDiv.innerHTML = '<div class="search-success">✅ Downloaded! Refreshing library...</div>';
|
||||
await fetchLibrary();
|
||||
setTimeout(() => {
|
||||
resultsDiv.innerHTML = originalContent;
|
||||
}, 2000);
|
||||
} else {
|
||||
resultsDiv.innerHTML = `<div class="search-error">❌ Download failed: ${result.error}</div>`;
|
||||
setTimeout(() => {
|
||||
resultsDiv.innerHTML = originalContent;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
resultsDiv.innerHTML = '<div class="search-error">❌ Download failed</div>';
|
||||
setTimeout(() => {
|
||||
resultsDiv.innerHTML = originalContent;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromServer(id, url, title) {
|
||||
const d = document.getElementById('display-' + id);
|
||||
|
|
@ -1382,33 +1281,6 @@ async function loadFromServer(id, url, title) {
|
|||
}
|
||||
}
|
||||
|
||||
// Download Functionality
|
||||
async function downloadFromPanel(deckId) {
|
||||
const input = document.getElementById('search-input-' + deckId);
|
||||
const statusDiv = document.getElementById('download-status-' + deckId);
|
||||
const qualitySelect = document.getElementById('quality-' + deckId);
|
||||
const url = input.value.trim();
|
||||
if (!url) return;
|
||||
|
||||
statusDiv.innerHTML = '<div class="progress-text">Downloading...</div>';
|
||||
try {
|
||||
const res = await fetch('/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url, quality: qualitySelect.value })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
statusDiv.innerHTML = '✅ Complete!';
|
||||
fetchLibrary();
|
||||
setTimeout(() => statusDiv.innerHTML = '', 3000);
|
||||
} else {
|
||||
statusDiv.innerHTML = '❌ Failed';
|
||||
}
|
||||
} catch (e) {
|
||||
statusDiv.innerHTML = '❌ Error';
|
||||
}
|
||||
}
|
||||
|
||||
// Settings
|
||||
function toggleSettings() {
|
||||
|
|
@ -1645,6 +1517,9 @@ function initSocket() {
|
|||
console.log('✅ Connected to streaming server');
|
||||
console.log(` Socket ID: ${socket.id}`);
|
||||
console.log(` Transport: ${socket.io.engine.transport.name}`);
|
||||
|
||||
// Get initial listener count soon as we connect
|
||||
socket.emit('get_listener_count');
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
|
|
@ -1807,7 +1682,8 @@ function startBroadcast() {
|
|||
document.getElementById('broadcast-status').classList.add('live');
|
||||
|
||||
if (!socket) initSocket();
|
||||
socket.emit('start_broadcast');
|
||||
const bitrateValue = document.getElementById('stream-quality').value + 'k';
|
||||
socket.emit('start_broadcast', { bitrate: bitrateValue });
|
||||
socket.emit('get_listener_count');
|
||||
|
||||
console.log('✅ Server-side broadcast started');
|
||||
|
|
@ -2010,7 +1886,8 @@ function startBroadcast() {
|
|||
|
||||
// Notify server that broadcast is active (listeners use MP3 stream)
|
||||
if (!socket) initSocket();
|
||||
socket.emit('start_broadcast');
|
||||
const bitrateValue = document.getElementById('stream-quality').value + 'k';
|
||||
socket.emit('start_broadcast', { bitrate: bitrateValue });
|
||||
socket.emit('get_listener_count');
|
||||
|
||||
console.log('✅ Broadcasting started successfully!');
|
||||
|
|
@ -2170,37 +2047,38 @@ function toggleAutoStream(enabled) {
|
|||
function startRemoteRelay() {
|
||||
const urlInput = document.getElementById('remote-stream-url');
|
||||
const url = urlInput.value.trim();
|
||||
|
||||
|
||||
if (!url) {
|
||||
alert('Please enter a remote stream URL');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!socket) initSocket();
|
||||
|
||||
|
||||
// Stop any existing broadcast first
|
||||
if (isBroadcasting) {
|
||||
stopBroadcast();
|
||||
}
|
||||
|
||||
|
||||
console.log('🔗 Starting remote relay for:', url);
|
||||
|
||||
|
||||
// Update UI
|
||||
document.getElementById('start-relay-btn').style.display = 'none';
|
||||
document.getElementById('stop-relay-btn').style.display = 'inline-block';
|
||||
document.getElementById('relay-status').textContent = 'Connecting to remote stream...';
|
||||
document.getElementById('relay-status').style.color = '#00f3ff';
|
||||
|
||||
socket.emit('start_remote_relay', { url: url });
|
||||
|
||||
const bitrateValue = document.getElementById('stream-quality').value + 'k';
|
||||
socket.emit('start_remote_relay', { url: url, bitrate: bitrateValue });
|
||||
}
|
||||
|
||||
function stopRemoteRelay() {
|
||||
if (!socket) return;
|
||||
|
||||
|
||||
console.log('🛑 Stopping remote relay');
|
||||
|
||||
|
||||
socket.emit('stop_remote_relay');
|
||||
|
||||
|
||||
// Update UI
|
||||
document.getElementById('start-relay-btn').style.display = 'inline-block';
|
||||
document.getElementById('stop-relay-btn').style.display = 'none';
|
||||
|
|
|
|||
134
server.py
134
server.py
|
|
@ -8,12 +8,13 @@ import subprocess
|
|||
import threading
|
||||
import queue
|
||||
import time
|
||||
import collections
|
||||
from flask import Flask, send_from_directory, jsonify, request, session, Response, stream_with_context
|
||||
from flask_socketio import SocketIO, emit
|
||||
from dotenv import load_dotenv
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
import downloader
|
||||
|
||||
|
||||
|
||||
def _load_config():
|
||||
|
|
@ -46,7 +47,8 @@ dj_sids = set()
|
|||
# This allows listeners on browsers that don't support WebM/Opus via MediaSource
|
||||
# (notably some Safari / locked-down environments) to still hear the stream.
|
||||
_ffmpeg_proc = None
|
||||
_ffmpeg_in_q = queue.Queue(maxsize=200)
|
||||
_ffmpeg_in_q = queue.Queue(maxsize=40)
|
||||
_current_bitrate = "192k"
|
||||
_mp3_clients = set() # set[queue.Queue]
|
||||
_mp3_lock = threading.Lock()
|
||||
_transcode_threads_started = False
|
||||
|
|
@ -54,6 +56,7 @@ _transcoder_bytes_out = 0
|
|||
_transcoder_last_error = None
|
||||
_last_audio_chunk_ts = 0.0
|
||||
_remote_stream_url = None # For relaying remote streams
|
||||
_mp3_preroll = collections.deque(maxlen=60) # Pre-roll (~2.5s at 192k)
|
||||
|
||||
|
||||
def _start_transcoder_if_needed():
|
||||
|
|
@ -71,7 +74,9 @@ def _start_transcoder_if_needed():
|
|||
'-i', _remote_stream_url,
|
||||
'-vn',
|
||||
'-acodec', 'libmp3lame',
|
||||
'-b:a', '192k',
|
||||
'-b:a', _current_bitrate,
|
||||
'-tune', 'zerolatency',
|
||||
'-flush_packets', '1',
|
||||
'-f', 'mp3',
|
||||
'pipe:1',
|
||||
]
|
||||
|
|
@ -84,7 +89,9 @@ def _start_transcoder_if_needed():
|
|||
'-i', 'pipe:0',
|
||||
'-vn',
|
||||
'-acodec', 'libmp3lame',
|
||||
'-b:a', '192k',
|
||||
'-b:a', _current_bitrate,
|
||||
'-tune', 'zerolatency',
|
||||
'-flush_packets', '1',
|
||||
'-f', 'mp3',
|
||||
'pipe:1',
|
||||
]
|
||||
|
|
@ -135,15 +142,19 @@ def _start_transcoder_if_needed():
|
|||
return
|
||||
while True:
|
||||
try:
|
||||
data = proc.stdout.read(4096)
|
||||
data = proc.stdout.read(1024)
|
||||
except Exception:
|
||||
_transcoder_last_error = 'stdout read failed'
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
_transcoder_bytes_out += len(data)
|
||||
|
||||
# Store in pre-roll
|
||||
with _mp3_lock:
|
||||
_mp3_preroll.append(data)
|
||||
clients = list(_mp3_clients)
|
||||
|
||||
for q in clients:
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
|
|
@ -158,7 +169,7 @@ def _start_transcoder_if_needed():
|
|||
|
||||
|
||||
def _stop_transcoder():
|
||||
global _ffmpeg_proc
|
||||
global _ffmpeg_proc, _transcode_threads_started
|
||||
try:
|
||||
_ffmpeg_in_q.put_nowait(None)
|
||||
except Exception:
|
||||
|
|
@ -166,6 +177,12 @@ def _stop_transcoder():
|
|||
|
||||
proc = _ffmpeg_proc
|
||||
_ffmpeg_proc = None
|
||||
|
||||
# Reset thread flag so they can be re-launched if needed
|
||||
# (The existing threads will exit cleanly on None/EOF)
|
||||
_transcode_threads_started = False
|
||||
_mp3_preroll.clear()
|
||||
|
||||
if proc is None:
|
||||
return
|
||||
try:
|
||||
|
|
@ -203,67 +220,6 @@ def setup_shared_routes(app):
|
|||
})
|
||||
return jsonify(library)
|
||||
|
||||
@app.route('/download', methods=['POST'])
|
||||
def download():
|
||||
data = request.get_json(silent=True) or {}
|
||||
url = data.get('url')
|
||||
quality = data.get('quality', '320')
|
||||
if not url:
|
||||
return jsonify({"success": False, "error": "No URL provided"}), 400
|
||||
result = downloader.download_mp3(url, quality)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/search_youtube', methods=['GET'])
|
||||
def search_youtube():
|
||||
query = request.args.get('q', '')
|
||||
if not query:
|
||||
return jsonify({"success": False, "error": "No query provided"}), 400
|
||||
|
||||
# Get API key from environment variable
|
||||
api_key = os.environ.get('YOUTUBE_API_KEY', '')
|
||||
if not api_key:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "YouTube API key not configured. Set YOUTUBE_API_KEY environment variable."
|
||||
}), 500
|
||||
|
||||
try:
|
||||
import requests
|
||||
# Search YouTube using Data API v3
|
||||
url = 'https://www.googleapis.com/youtube/v3/search'
|
||||
params = {
|
||||
'part': 'snippet',
|
||||
'q': query,
|
||||
'type': 'video',
|
||||
'videoCategoryId': '10', # Music category
|
||||
'maxResults': 20,
|
||||
'key': api_key
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params)
|
||||
data = response.json()
|
||||
|
||||
if 'error' in data:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": data['error'].get('message', 'YouTube API error')
|
||||
}), 400
|
||||
|
||||
# Format results
|
||||
results = []
|
||||
for item in data.get('items', []):
|
||||
results.append({
|
||||
'videoId': item['id']['videoId'],
|
||||
'title': item['snippet']['title'],
|
||||
'channel': item['snippet']['channelTitle'],
|
||||
'thumbnail': item['snippet']['thumbnails']['medium']['url'],
|
||||
'url': f"https://www.youtube.com/watch?v={item['id']['videoId']}"
|
||||
})
|
||||
|
||||
return jsonify({"success": True, "results": results})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/<path:filename>')
|
||||
def serve_static(filename):
|
||||
|
|
@ -313,8 +269,14 @@ def setup_shared_routes(app):
|
|||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||
return jsonify({"success": False, "error": "MP3 stream not available"}), 503
|
||||
|
||||
client_q: queue.Queue = queue.Queue(maxsize=200)
|
||||
client_q: queue.Queue = queue.Queue(maxsize=20)
|
||||
with _mp3_lock:
|
||||
# Burst pre-roll to new client so they start playing instantly
|
||||
for chunk in _mp3_preroll:
|
||||
try:
|
||||
client_q.put_nowait(chunk)
|
||||
except Exception:
|
||||
break
|
||||
_mp3_clients.add(client_q)
|
||||
|
||||
def gen():
|
||||
|
|
@ -488,11 +450,20 @@ def dj_start(data=None):
|
|||
session['is_dj'] = True
|
||||
print("🎙️ Broadcast -> ACTIVE")
|
||||
|
||||
if data and 'bitrate' in data:
|
||||
global _current_bitrate
|
||||
_current_bitrate = data['bitrate']
|
||||
print(f"📡 Setting stream bitrate to: {_current_bitrate}")
|
||||
|
||||
_start_transcoder_if_needed()
|
||||
|
||||
listener_socketio.emit('broadcast_started', namespace='/')
|
||||
listener_socketio.emit('stream_status', {'active': True}, namespace='/')
|
||||
|
||||
@dj_socketio.on('get_listener_count')
|
||||
def dj_get_listener_count():
|
||||
emit('listener_count', {'count': len(listener_sids)})
|
||||
|
||||
@dj_socketio.on('stop_broadcast')
|
||||
def dj_stop():
|
||||
broadcast_state['active'] = False
|
||||
|
|
@ -516,10 +487,16 @@ def dj_start_remote_relay(data):
|
|||
if broadcast_state['active']:
|
||||
dj_stop()
|
||||
|
||||
global _remote_stream_url, _current_bitrate
|
||||
_remote_stream_url = url
|
||||
broadcast_state['active'] = True
|
||||
broadcast_state['remote_relay'] = True
|
||||
session['is_dj'] = True
|
||||
|
||||
if data and 'bitrate' in data:
|
||||
_current_bitrate = data['bitrate']
|
||||
print(f"📡 Setting relay bitrate to: {_current_bitrate}")
|
||||
|
||||
print(f"🔗 Starting remote relay from: {url}")
|
||||
|
||||
_start_transcoder_if_needed()
|
||||
|
|
@ -573,12 +550,12 @@ def listener_connect():
|
|||
|
||||
@listener_socketio.on('disconnect')
|
||||
def listener_disconnect():
|
||||
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_sids.discard(request.sid)
|
||||
count = len(listener_sids)
|
||||
print(f"❌ Listener left. Total: {count}")
|
||||
# Notify BOTH namespaces
|
||||
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
|
||||
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
|
||||
|
||||
@listener_socketio.on('join_listener')
|
||||
def listener_join():
|
||||
|
|
@ -604,6 +581,14 @@ def get_mixer_status():
|
|||
def audio_sync_queue(data):
|
||||
pass
|
||||
|
||||
def _listener_count_sync_loop():
|
||||
"""Periodic background sync to ensure listener count is always accurate."""
|
||||
while True:
|
||||
count = len(listener_sids)
|
||||
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
|
||||
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
|
||||
eventlet.sleep(5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 50)
|
||||
|
|
@ -617,5 +602,6 @@ if __name__ == '__main__':
|
|||
print("✅ Local Radio server ready")
|
||||
|
||||
# Run both servers using eventlet's spawn
|
||||
eventlet.spawn(_listener_count_sync_loop)
|
||||
eventlet.spawn(dj_socketio.run, dj_app, host='0.0.0.0', port=5000, debug=False)
|
||||
listener_socketio.run(listener_app, host='0.0.0.0', port=5001, debug=False)
|
||||
|
|
|
|||
224
style.css
224
style.css
|
|
@ -240,144 +240,6 @@ header h1 {
|
|||
}
|
||||
}
|
||||
|
||||
/* YouTube Search */
|
||||
.youtube-search-container {
|
||||
padding: 10px 15px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.youtube-search-container input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid var(--primary-cyan);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 0 10px rgba(0, 243, 255, 0.2);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.youtube-search-container input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-cyan);
|
||||
box-shadow: 0 0 15px rgba(0, 243, 255, 0.4);
|
||||
}
|
||||
|
||||
.youtube-results {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.youtube-result-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.youtube-result-card:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--primary-cyan);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.result-thumbnail {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.result-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-channel {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-download-btn {
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 243, 255, 0.2);
|
||||
border: 1px solid var(--primary-cyan);
|
||||
color: var(--primary-cyan);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-download-btn:hover {
|
||||
background: rgba(0, 243, 255, 0.3);
|
||||
box-shadow: 0 0 10px rgba(0, 243, 255, 0.4);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.result-download-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.search-loading,
|
||||
.search-error,
|
||||
.search-success,
|
||||
.search-empty {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-loading {
|
||||
background: rgba(255, 187, 0, 0.1);
|
||||
color: #ffbb00;
|
||||
border: 1px solid rgba(255, 187, 0, 0.3);
|
||||
}
|
||||
|
||||
.search-error {
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
color: #ff4444;
|
||||
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.search-success {
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
color: #00ff00;
|
||||
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #666;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.library-list {
|
||||
flex: 1;
|
||||
|
|
@ -612,92 +474,6 @@ canvas {
|
|||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
/* SEARCH & DOWNLOAD */
|
||||
.start-group {
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: #111;
|
||||
border: 1px solid #444;
|
||||
color: #fff;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
background: var(--primary-cyan);
|
||||
border: 1px solid var(--primary-cyan);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
color: #000;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 0 10px rgba(0, 243, 255, 0.2);
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
box-shadow: 0 0 20px rgba(0, 243, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.download-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#deck-B .download-btn {
|
||||
background: var(--secondary-magenta);
|
||||
border-color: var(--secondary-magenta);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 10px rgba(188, 19, 254, 0.2);
|
||||
}
|
||||
|
||||
#deck-B .download-btn:hover {
|
||||
box-shadow: 0 0 20px rgba(188, 19, 254, 0.5);
|
||||
}
|
||||
|
||||
|
||||
.quality-selector {
|
||||
background: rgba(10, 10, 12, 0.8);
|
||||
color: var(--text-color);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.download-status .loading {
|
||||
color: #ffa500;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.download-status .success {
|
||||
color: #00ff00;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.download-status .error {
|
||||
color: #ff4444;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue