techdj/server.py

745 lines
27 KiB
Python

# Monkey patch MUST be first - before any other imports!
import eventlet
eventlet.monkey_patch()
import os
import json
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()
def _load_config():
"""Loads optional config.json from the project root.
If missing or invalid, returns an empty dict.
"""
try:
with open('config.json', 'r', encoding='utf-8') as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except FileNotFoundError:
return {}
except Exception:
return {}
CONFIG = _load_config()
DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip()
DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD)
# Relay State
broadcast_state = {
'active': False,
}
listener_sids = set()
dj_sids = set()
# === Optional MP3 fallback stream (server-side transcoding) ===
_ffmpeg_proc = None
_ffmpeg_in_q = queue.Queue(maxsize=20) # Optimized for low-latency live streaming
_current_bitrate = "192k"
_mp3_clients = set() # set[queue.Queue]
_mp3_lock = threading.Lock()
_transcoder_bytes_out = 0
_transcoder_last_error = None
_last_audio_chunk_ts = 0.0
_mp3_preroll = collections.deque(maxlen=512) # Larger pre-roll (~512KB)
def _start_transcoder_if_needed(is_mp3_input=False):
global _ffmpeg_proc, _transcoder_last_error
# If already running, check if we need to restart
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
return
# Ensure stale process is cleaned up
if _ffmpeg_proc:
try: _ffmpeg_proc.terminate()
except: pass
_ffmpeg_proc = None
codec = 'copy' if is_mp3_input else 'libmp3lame'
cmd = [
'ffmpeg',
'-hide_banner',
'-loglevel', 'error',
'-fflags', 'nobuffer',
'-flags', 'low_delay',
'-probesize', '32',
'-analyzeduration', '0'
]
if is_mp3_input:
cmd.extend(['-f', 'mp3'])
cmd.extend([
'-i', 'pipe:0',
'-vn',
'-acodec', codec,
])
if not is_mp3_input:
cmd.extend(['-b:a', _current_bitrate])
cmd.extend([
'-flush_packets', '1',
'-f', 'mp3',
'pipe:1',
])
try:
_ffmpeg_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
except FileNotFoundError:
_ffmpeg_proc = None
print('WARNING: ffmpeg not found; /stream.mp3 fallback disabled')
return
mode_str = "PASSTHROUGH (copy)" if is_mp3_input else f"TRANSCODE ({_current_bitrate})"
print(f'INFO: ffmpeg transcoder started ({mode_str})')
_transcoder_last_error = None
# Clear queue to avoid stale data
while not _ffmpeg_in_q.empty():
try: _ffmpeg_in_q.get_nowait()
except: break
# Define threads INSIDE so they close over THIS specific 'proc'
def _writer(proc):
global _transcoder_last_error
print(f"[THREAD] Transcoder writer started (PID: {proc.pid})")
while proc.poll() is None:
try:
chunk = _ffmpeg_in_q.get(timeout=1.0)
if chunk is None: break
if proc.stdin:
proc.stdin.write(chunk)
proc.stdin.flush()
except queue.Empty:
continue
except (BrokenPipeError, ConnectionResetError):
_transcoder_last_error = "Broken pipe in writer"
break
except Exception as e:
print(f"WARNING: Transcoder writer error: {e}")
_transcoder_last_error = str(e)
break
# Ensure process is killed if thread exits unexpectedly
if proc.poll() is None:
try: proc.terminate()
except: pass
print(f"[THREAD] Transcoder writer finished (PID: {proc.pid})")
def _reader(proc):
global _transcoder_bytes_out, _transcoder_last_error
print(f"[THREAD] Transcoder reader started (PID: {proc.pid})")
while proc.poll() is None:
try:
# Smaller read for smoother delivery (1KB)
# This prevents buffering delays at lower bitrates
data = proc.stdout.read(1024)
if not data: break
_transcoder_bytes_out += len(data)
with _mp3_lock:
_mp3_preroll.append(data)
clients = list(_mp3_clients)
for q in clients:
try:
q.put_nowait(data)
except queue.Full:
# Client is too slow, skip this chunk for them
pass
except Exception as e:
print(f"WARNING: Transcoder reader error: {e}")
_transcoder_last_error = str(e)
break
# Ensure process is killed if thread exits unexpectedly
if proc.poll() is None:
try: proc.terminate()
except: pass
print(f"[THREAD] Transcoder reader finished (PID: {proc.pid})")
# Start greenlets/threads for THIS process specifically
eventlet.spawn(_writer, _ffmpeg_proc)
eventlet.spawn(_reader, _ffmpeg_proc)
def _stop_transcoder():
global _ffmpeg_proc
print("STOPPING: Transcoder process")
# Signal threads to stop via the queue
try: _ffmpeg_in_q.put_nowait(None)
except: pass
# Shutdown the process
proc = _ffmpeg_proc
_ffmpeg_proc = None
if proc:
try:
proc.terminate()
# Drain stdout/stderr to satisfy OS buffers
proc.communicate(timeout=1.0)
except:
try: proc.kill()
except: pass
# Clear client state
with _mp3_lock:
clients = list(_mp3_clients)
for q in clients:
try: q.put_nowait(None)
except: pass
_mp3_clients.clear()
_mp3_preroll.clear()
def _feed_transcoder(data: bytes):
global _last_audio_chunk_ts
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
# If active but dead, restart it automatically
if broadcast_state.get('active'):
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
else:
return
_last_audio_chunk_ts = time.time()
try:
_ffmpeg_in_q.put_nowait(data)
except queue.Full:
# Drop chunk if overflow to prevent memory bloat
pass
# Load settings to get MUSIC_FOLDER
def _load_settings():
try:
if os.path.exists('settings.json'):
with open('settings.json', 'r', encoding='utf-8') as f:
return json.load(f)
except:
pass
return {}
SETTINGS = _load_settings()
MUSIC_FOLDER = SETTINGS.get('library', {}).get('music_folder', 'music')
# Ensure music folder exists
if not os.path.exists(MUSIC_FOLDER):
try:
os.makedirs(MUSIC_FOLDER)
except:
# Fallback to default if custom path fails
MUSIC_FOLDER = "music"
if not os.path.exists(MUSIC_FOLDER):
os.makedirs(MUSIC_FOLDER)
# Helper for shared routes
def setup_shared_routes(app):
@app.route('/library.json')
def get_library():
library = []
global MUSIC_FOLDER
if os.path.exists(MUSIC_FOLDER):
# Recursively find music files if desired, or stay top-level.
# The prompt says "choose which folder", so maybe top-level of that folder is fine.
for root, dirs, files in os.walk(MUSIC_FOLDER):
for filename in sorted(files):
if filename.lower().endswith(('.mp3', '.m4a', '.wav', '.flac', '.ogg')):
rel_path = os.path.relpath(os.path.join(root, filename), MUSIC_FOLDER)
library.append({
"title": os.path.splitext(filename)[0],
"file": f"music_proxy/{rel_path}"
})
break # Only top level for now to keep it simple, or remove break for recursive
return jsonify(library)
@app.route('/music_proxy/<path:filename>')
def music_proxy(filename):
return send_from_directory(MUSIC_FOLDER, filename)
@app.route('/browse_directories', methods=['GET'])
def browse_directories():
path = request.args.get('path', os.path.expanduser('~'))
try:
entries = []
if os.path.exists(path) and os.path.isdir(path):
# Add parent
parent = os.path.dirname(os.path.abspath(path))
entries.append({"name": "..", "path": parent, "isDir": True})
for item in sorted(os.listdir(path)):
full_path = os.path.join(path, item)
if os.path.isdir(full_path) and not item.startswith('.'):
entries.append({
"name": item,
"path": full_path,
"isDir": True
})
return jsonify({"success": True, "path": os.path.abspath(path), "entries": entries})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/update_settings', methods=['POST'])
def update_settings():
try:
data = request.get_json()
# Load existing
settings = _load_settings()
# Update selectively
if 'library' not in settings: settings['library'] = {}
if 'music_folder' in data.get('library', {}):
new_folder = data['library']['music_folder']
if os.path.exists(new_folder) and os.path.isdir(new_folder):
settings['library']['music_folder'] = new_folder
global MUSIC_FOLDER
MUSIC_FOLDER = new_folder
else:
return jsonify({"success": False, "error": "Invalid folder path"}), 400
with open('settings.json', 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=4)
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/<path:filename>')
def serve_static(filename):
# Block access to sensitive files
blocked = ('.py', '.pyc', '.env', '.json', '.sh', '.bak', '.log', '.pem', '.key')
# Allow specific safe JSON/JS/CSS files
allowed_extensions = ('.css', '.js', '.html', '.htm', '.png', '.jpg', '.jpeg',
'.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.map')
if filename.endswith(blocked) and not filename.endswith(('.css', '.js')):
from flask import abort
abort(403)
# Prevent path traversal
if '..' in filename or filename.startswith('/'):
from flask import abort
abort(403)
response = send_from_directory('.', filename)
if filename.endswith(('.css', '.js', '.html')):
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
@app.route('/')
def index():
return send_from_directory('.', 'index.html')
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({"success": False, "error": "No file provided"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"success": False, "error": "No file selected"}), 400
allowed_exts = ('.mp3', '.m4a', '.wav', '.flac', '.ogg')
ext = os.path.splitext(file.filename)[1].lower()
if ext not in allowed_exts:
return jsonify({"success": False, "error": f"Supported formats: {', '.join(allowed_exts)}"}), 400
# Sanitize filename (keep extension)
import re
name_without_ext = os.path.splitext(file.filename)[0]
name_without_ext = re.sub(r'[^\w\s-]', '', name_without_ext)
name_without_ext = re.sub(r'\s+', ' ', name_without_ext).strip()
filename = f"{name_without_ext}{ext}"
filepath = os.path.join(MUSIC_FOLDER, filename)
try:
file.save(filepath)
print(f"UPLOADED: {filename}")
return jsonify({"success": True, "filename": filename})
except Exception as e:
print(f"ERROR: Upload error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/save_keymaps', methods=['POST'])
def save_keymaps():
try:
data = request.get_json()
with open('keymaps.json', 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4)
print("SAVED: Keymaps saved to keymaps.json")
return jsonify({"success": True})
except Exception as e:
print(f"ERROR: Save keymaps error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/load_keymaps', methods=['GET'])
def load_keymaps():
try:
if os.path.exists('keymaps.json'):
with open('keymaps.json', 'r', encoding='utf-8') as f:
data = json.load(f)
return jsonify({"success": True, "keymaps": data})
else:
return jsonify({"success": True, "keymaps": None})
except Exception as e:
print(f"ERROR: Load keymaps error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/stream.mp3')
def stream_mp3():
# Streaming response from the ffmpeg transcoder output.
# If ffmpeg isn't available, return 503.
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
return jsonify({"success": False, "error": "MP3 stream not available"}), 503
print(f"LISTENER: New listener joined stream (Bursting {_mp3_preroll.maxlen} frames)")
client_q: queue.Queue = queue.Queue(maxsize=500)
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():
try:
while True:
try:
chunk = client_q.get(timeout=30)
except queue.Empty:
# No data for 30s - check if broadcast is still active
if not broadcast_state.get('active'):
break
continue
if chunk is None:
break
yield chunk
finally:
with _mp3_lock:
_mp3_clients.discard(client_q)
print(f"LISTENER: Listener disconnected from stream")
return Response(stream_with_context(gen()), content_type='audio/mpeg', headers={
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Connection': 'keep-alive',
'Pragma': 'no-cache',
'Expires': '0',
'X-Content-Type-Options': 'nosniff'
})
@app.route('/stream_debug')
def stream_debug():
proc = _ffmpeg_proc
running = proc is not None and proc.poll() is None
return jsonify({
'broadcast_active': broadcast_state.get('active', False),
'broadcast_mimeType': broadcast_state.get('mimeType'),
'ffmpeg_running': running,
'ffmpeg_found': (proc is not None),
'mp3_clients': len(_mp3_clients),
'transcoder_bytes_out': _transcoder_bytes_out,
'transcoder_last_error': _transcoder_last_error,
'last_audio_chunk_ts': _last_audio_chunk_ts,
})
# === DJ SERVER (Port 5000) ===
dj_app = Flask(__name__, static_folder='.', static_url_path='')
dj_app.config['SECRET_KEY'] = 'dj_panel_secret'
setup_shared_routes(dj_app)
@dj_app.before_request
def _protect_dj_panel():
"""Optionally require a password for the DJ panel only (port 5000).
This does not affect the listener server (port 5001).
"""
if not DJ_AUTH_ENABLED:
return None
# Allow login/logout endpoints
if request.path in ('/login', '/logout'):
return None
# If already authenticated, allow
if session.get('dj_authed') is True:
return None
# Redirect everything else to login
return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
"<body>Redirecting to <a href='/login'>/login</a>...</body></html>",
302,
{'Location': '/login'}
)
@dj_app.route('/login', methods=['GET', 'POST'])
def dj_login():
if not DJ_AUTH_ENABLED:
# If auth is disabled, just go to the panel.
session['dj_authed'] = True
return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
"<body>Auth disabled. Redirecting...</body></html>",
302,
{'Location': '/'}
)
error = None
if request.method == 'POST':
pw = (request.form.get('password') or '').strip()
if pw == DJ_PANEL_PASSWORD:
session['dj_authed'] = True
return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
"<body>Logged in. Redirecting...</body></html>",
302,
{'Location': '/'}
)
error = 'Invalid password'
# Minimal inline login page (no new assets)
return f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>TechDJ - DJ Login</title>
<style>
body {{ background:#0a0a12; color:#eee; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin:0; }}
.wrap {{ min-height:100vh; display:flex; align-items:center; justify-content:center; padding:24px; }}
.card {{ width:100%; max-width:420px; background:rgba(10,10,20,0.85); border:2px solid #bc13fe; border-radius:16px; padding:24px; box-shadow:0 0 40px rgba(188,19,254,0.25); }}
h1 {{ margin:0 0 16px 0; font-size:22px; }}
label {{ display:block; margin:12px 0 8px; opacity:0.9; }}
input {{ width:100%; padding:12px; border-radius:10px; border:1px solid rgba(255,255,255,0.15); background:rgba(0,0,0,0.35); color:#fff; }}
button {{ width:100%; margin-top:14px; padding:12px; border-radius:10px; border:2px solid #bc13fe; background:rgba(188,19,254,0.15); color:#fff; font-weight:700; cursor:pointer; }}
.err {{ margin-top:12px; color:#ffb3ff; }}
.hint {{ margin-top:10px; font-size:12px; opacity:0.7; }}
</style>
</head>
<body>
<div class=\"wrap\">
<div class=\"card\">
<h1>DJ Panel Locked</h1>
<form method=\"post\" action=\"/login\">
<label for=\"password\">Password</label>
<input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" autofocus />
<button type=\"submit\">Unlock DJ Panel</button>
{f"<div class='err'>{error}</div>" if error else ""}
<div class=\"hint\">Set/disable this in config.json (dj_panel_password).</div>
</form>
</div>
</div>
</body>
</html>"""
@dj_app.route('/logout')
def dj_logout():
session.pop('dj_authed', None)
return (
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
"<body>Logged out. Redirecting...</body></html>",
302,
{'Location': '/login'}
)
dj_socketio = SocketIO(
dj_app,
cors_allowed_origins="*",
async_mode='eventlet',
max_http_buffer_size=1e8, # 100MB buffer
ping_timeout=10,
ping_interval=5,
logger=False,
engineio_logger=False
)
@dj_socketio.on('connect')
def dj_connect():
if DJ_AUTH_ENABLED and session.get('dj_authed') is not True:
print(f"REJECTED: DJ socket rejected (unauthorized): {request.sid}")
return False
print(f"STREAMPANEL: DJ connected: {request.sid}")
dj_sids.add(request.sid)
@dj_socketio.on('disconnect')
def dj_disconnect():
dj_sids.discard(request.sid)
print("WARNING: DJ disconnected - broadcast will continue until manually stopped")
def stop_broadcast_after_timeout():
"""No longer used - broadcasts don't auto-stop"""
pass
@dj_socketio.on('start_broadcast')
def dj_start(data=None):
broadcast_state['active'] = True
session['is_dj'] = True
print("BROADCAST: ACTIVE")
is_mp3_input = False
if data:
if 'bitrate' in data:
global _current_bitrate
_current_bitrate = data['bitrate']
print(f"BITRATE: Setting stream bitrate to: {_current_bitrate}")
if data.get('format') == 'mp3':
is_mp3_input = True
broadcast_state['is_mp3_input'] = is_mp3_input
# Clear pre-roll for fresh start
with _mp3_lock:
_mp3_preroll.clear()
_start_transcoder_if_needed(is_mp3_input=is_mp3_input)
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
session['is_dj'] = False
print("STOPPED: DJ stopped broadcasting")
_stop_transcoder()
listener_socketio.emit('broadcast_stopped', namespace='/')
listener_socketio.emit('stream_status', {'active': False}, namespace='/')
@dj_socketio.on('audio_chunk')
def dj_audio(data):
# MP3-only mode: do not relay raw chunks to listeners; feed transcoder only.
if broadcast_state['active']:
# Ensure MP3 fallback transcoder is running (if ffmpeg is installed)
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
# If we don't know the format, default to transcode,
# but usually start_broadcast handles this
_start_transcoder_if_needed()
if isinstance(data, (bytes, bytearray)):
_feed_transcoder(bytes(data))
# === 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)
# Block write/admin endpoints on the listener server
@listener_app.before_request
def _restrict_listener_routes():
"""Prevent listeners from accessing DJ-only write endpoints."""
blocked_paths = ('/update_settings', '/upload', '/save_keymaps', '/browse_directories')
if request.path in blocked_paths:
from flask import abort
abort(403)
listener_socketio = SocketIO(
listener_app,
cors_allowed_origins="*",
async_mode='eventlet',
max_http_buffer_size=1e8, # 100MB buffer
ping_timeout=10,
ping_interval=5,
logger=False,
engineio_logger=False
)
@listener_socketio.on('connect')
def listener_connect():
print(f"LISTENER: Listener Socket Connected: {request.sid}")
@listener_socketio.on('disconnect')
def listener_disconnect():
listener_sids.discard(request.sid)
count = len(listener_sids)
print(f"REMOVED: 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():
if request.sid not in listener_sids:
listener_sids.add(request.sid)
count = len(listener_sids)
print(f"LISTENER: 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('get_listener_count')
def listener_get_count():
emit('listener_count', {'count': len(listener_sids)})
# DJ Panel Routes (No engine commands needed in local mode)
def _transcoder_watchdog():
"""Periodic check to ensure the transcoder stays alive during active broadcasts."""
while True:
if broadcast_state.get('active'):
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
# Only log if it's actually dead and supposed to be alive
print("WARNING: Watchdog: Transcoder dead during active broadcast, reviving...")
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
eventlet.sleep(5)
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)
print("TECHDJ PRO - DUAL PORT ARCHITECTURE")
print("=" * 50)
# Ports from environment or defaults
dj_port = int(os.environ.get('DJ_PORT', 5000))
listen_port = int(os.environ.get('LISTEN_PORT', 5001))
print(f"URL: DJ PANEL API: http://0.0.0.0:{dj_port}")
print(f"URL: LISTEN PAGE: http://0.0.0.0:{listen_port}")
print("=" * 50)
# Audio engine DISABLED
print(f"READY: Local Radio server ready on ports {dj_port} & {listen_port}")
# Run both servers using eventlet's spawn
eventlet.spawn(_listener_count_sync_loop)
eventlet.spawn(_transcoder_watchdog)
eventlet.spawn(dj_socketio.run, dj_app, host='0.0.0.0', port=dj_port, debug=False)
listener_socketio.run(listener_app, host='0.0.0.0', port=listen_port, debug=False)