Update audio_streamer.py
This commit is contained in:
@@ -1,11 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Audio Streamer Module.
|
||||
|
||||
This module provides an HTTP streaming server that distributes audio to
|
||||
connected clients (like Shoutcast/Icecast). It pipes audio from an FFMPEG
|
||||
process into an asyncio buffer and serves it via aiohttp.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
@@ -21,16 +14,13 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamSource(Enum):
|
||||
FILE = "file"
|
||||
URL = "url"
|
||||
SILENCE = "silence"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StreamState:
|
||||
"""Holds metadata about the current stream."""
|
||||
source_type: StreamSource = StreamSource.SILENCE
|
||||
source_path: str = ""
|
||||
title: str = "Silence"
|
||||
@@ -39,16 +29,7 @@ class StreamState:
|
||||
duration: float = 0.0
|
||||
listeners: int = 0
|
||||
|
||||
|
||||
class AudioStreamer:
|
||||
"""
|
||||
Manages the FFMPEG subprocess and HTTP server for audio streaming.
|
||||
|
||||
Attributes:
|
||||
_audio_buffer: Queue holding audio chunks from FFMPEG.
|
||||
_clients: Set of active HTTP response objects to write to.
|
||||
"""
|
||||
|
||||
CHUNK_SIZE = 2048
|
||||
MAX_BUFFER_SIZE = 30
|
||||
MAX_CLIENTS = 50
|
||||
@@ -97,7 +78,6 @@ class AudioStreamer:
|
||||
return self.state.source_path
|
||||
|
||||
async def start(self):
|
||||
"""Initializes the web app and starts the broadcast loop."""
|
||||
self._shutdown = False
|
||||
self._app = web.Application()
|
||||
self._app.router.add_get(self.mount_point, self._handle_stream)
|
||||
@@ -112,10 +92,9 @@ class AudioStreamer:
|
||||
await self._site.start()
|
||||
|
||||
self._broadcast_task = asyncio.create_task(self._broadcast_loop())
|
||||
logger.info(f"HTTP streaming server started at {self.stream_url}")
|
||||
logger.info(f"🔊 HTTP streaming server started at {self.stream_url}")
|
||||
|
||||
async def stop(self):
|
||||
"""Stops FFMPEG, disconnects clients, and shuts down the server."""
|
||||
self._shutdown = True
|
||||
await self._stop_ffmpeg()
|
||||
|
||||
@@ -127,7 +106,6 @@ class AudioStreamer:
|
||||
pass
|
||||
self._broadcast_task = None
|
||||
|
||||
# Cleanup pending callback tasks
|
||||
for task in self._pending_tasks:
|
||||
task.cancel()
|
||||
try:
|
||||
@@ -151,10 +129,9 @@ class AudioStreamer:
|
||||
self._clear_buffer()
|
||||
gc.collect()
|
||||
|
||||
logger.info("HTTP streaming server stopped")
|
||||
logger.info("🔇 HTTP streaming server stopped")
|
||||
|
||||
def _clear_buffer(self):
|
||||
"""Drains the audio buffer to prevent memory bloat."""
|
||||
count = 0
|
||||
while not self._audio_buffer.empty():
|
||||
try:
|
||||
@@ -167,7 +144,6 @@ class AudioStreamer:
|
||||
logger.debug(f"Cleared {count} chunks from buffer")
|
||||
|
||||
async def play_file(self, filepath: str, title: str = "", artist: str = "") -> bool:
|
||||
"""Starts streaming a local file."""
|
||||
if not os.path.exists(filepath):
|
||||
logger.error(f"File not found: {filepath}")
|
||||
return False
|
||||
@@ -183,12 +159,11 @@ class AudioStreamer:
|
||||
success = await self._start_ffmpeg(filepath)
|
||||
if success:
|
||||
self.state.is_playing = True
|
||||
logger.info(f"Playing file: {filepath}")
|
||||
logger.info(f"▶️ Playing file: {filepath}")
|
||||
|
||||
return success
|
||||
|
||||
async def play_url(self, url: str, title: str = "") -> bool:
|
||||
"""Starts streaming from a remote URL."""
|
||||
await self._stop_ffmpeg()
|
||||
|
||||
self.state.source_type = StreamSource.URL
|
||||
@@ -200,7 +175,7 @@ class AudioStreamer:
|
||||
success = await self._start_ffmpeg(url)
|
||||
if success:
|
||||
self.state.is_playing = True
|
||||
logger.info(f"Playing URL: {url}")
|
||||
logger.info(f"📻 Playing URL: {url}")
|
||||
|
||||
return success
|
||||
|
||||
@@ -213,7 +188,6 @@ class AudioStreamer:
|
||||
self.state.artist = ""
|
||||
|
||||
async def _start_ffmpeg(self, source: str) -> bool:
|
||||
"""Constructs and executes the FFMPEG command to transcode audio."""
|
||||
codec_args = {
|
||||
'mp3': ['-c:a', 'libmp3lame', '-b:a', f'{self.bitrate}k'],
|
||||
'ogg': ['-c:a', 'libvorbis', '-b:a', f'{self.bitrate}k'],
|
||||
@@ -223,7 +197,6 @@ class AudioStreamer:
|
||||
|
||||
format_args = codec_args.get(self.stream_format, codec_args['mp3'])
|
||||
|
||||
# Optimize inputs for network streams
|
||||
if source.startswith(('http://', 'https://', 'rtmp://')):
|
||||
cmd = [
|
||||
'ffmpeg', '-reconnect', '1', '-reconnect_streamed', '1',
|
||||
@@ -255,7 +228,6 @@ class AudioStreamer:
|
||||
return False
|
||||
|
||||
async def _stop_ffmpeg(self):
|
||||
"""Terminates the FFMPEG process."""
|
||||
if self._stream_task:
|
||||
self._stream_task.cancel()
|
||||
try:
|
||||
@@ -286,7 +258,6 @@ class AudioStreamer:
|
||||
gc.collect()
|
||||
|
||||
async def _read_stream(self):
|
||||
"""Reads stdout from FFMPEG and pushes to the broadcast buffer."""
|
||||
try:
|
||||
while not self._shutdown:
|
||||
if not self._ffmpeg_process or self._ffmpeg_process.returncode is not None:
|
||||
@@ -304,7 +275,6 @@ class AudioStreamer:
|
||||
logger.debug('Stream EOF')
|
||||
break
|
||||
|
||||
# Drop frames if buffer is full to keep stream "live"
|
||||
dropped = 0
|
||||
while self._audio_buffer.full():
|
||||
try:
|
||||
@@ -324,7 +294,6 @@ class AudioStreamer:
|
||||
|
||||
self.state.is_playing = False
|
||||
|
||||
# Fire track end callback safely
|
||||
if self.on_track_end and not self._shutdown:
|
||||
task = asyncio.create_task(self._safe_callback(self.on_track_end))
|
||||
self._pending_tasks.add(task)
|
||||
@@ -348,7 +317,6 @@ class AudioStreamer:
|
||||
logger.error(f"Callback error: {e}")
|
||||
|
||||
async def _broadcast_loop(self):
|
||||
"""Reads from the buffer and writes to all connected HTTP clients."""
|
||||
try:
|
||||
while not self._shutdown:
|
||||
try:
|
||||
@@ -378,7 +346,6 @@ class AudioStreamer:
|
||||
logger.error(f"Broadcast loop error: {e}")
|
||||
|
||||
async def _get_duration(self, filepath: str) -> float:
|
||||
"""Uses ffprobe to determine file duration."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
|
||||
@@ -392,7 +359,6 @@ class AudioStreamer:
|
||||
return 0.0
|
||||
|
||||
async def _handle_stream(self, request: web.Request) -> web.StreamResponse:
|
||||
"""Handles HTTP GET requests for the audio stream."""
|
||||
if len(self._clients) >= self.MAX_CLIENTS:
|
||||
return web.Response(status=503, text="Server at capacity")
|
||||
|
||||
@@ -442,5 +408,12 @@ class AudioStreamer:
|
||||
return web.Response(text=json.dumps(status), content_type='application/json')
|
||||
|
||||
async def _handle_info(self, request: web.Request) -> web.Response:
|
||||
html = f"<!DOCTYPE html><html><head><title>XMPP Radio</title></head><body><h1>XMPP Radio Bot</h1><p><b>Now Playing:</b> {self.state.title}</p><p><b>Listeners:</b> {self.state.listeners}</p><audio controls autoplay><source src=\"{self.mount_point}\" type=\"audio/mpeg\"></audio></body></html>"
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html><head><title>XMPP Radio</title></head>
|
||||
<body>
|
||||
<h1>🎵 XMPP Radio Bot</h1>
|
||||
<p><b>Now Playing:</b> {self.state.title}</p>
|
||||
<p><b>Listeners:</b> {self.state.listeners}</p>
|
||||
<audio controls autoplay><source src="{self.mount_point}" type="audio/mpeg"></audio>
|
||||
</body></html>"""
|
||||
return web.Response(text=html, content_type='text/html')
|
||||
Reference in New Issue
Block a user