icy debug method added
This commit is contained in:
1 parent
4c2a4c8937
commit
e986af95c6
2 files changed
+58
-27
No files matched your search
@@ -158,6 +158,7 @@ class RadioCommands:
|
|||||||
|
|
||||||
# Spawn ICY metadata poller if this station supports it
|
# Spawn ICY metadata poller if this station supports it
|
||||||
if station["url"] in self._icy_urls:
|
if station["url"] in self._icy_urls:
|
||||||
|
log.info("Starting ICY poller for %s", station["name"])
|
||||||
self._cancel_icy_poller(guild.id)
|
self._cancel_icy_poller(guild.id)
|
||||||
poller = IcyMetadataPoller(
|
poller = IcyMetadataPoller(
|
||||||
station["url"],
|
station["url"],
|
||||||
@@ -166,6 +167,9 @@ class RadioCommands:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._icy_tasks[guild.id] = asyncio.create_task(poller.run())
|
self._icy_tasks[guild.id] = asyncio.create_task(poller.run())
|
||||||
|
else:
|
||||||
|
log.info("No ICY support for %s (%s not in set of %d URLs)",
|
||||||
|
station["name"], station["url"], len(self._icy_urls))
|
||||||
|
|
||||||
# ── ICY now-playing embed ─────────────────────────────────────────────────
|
# ── ICY now-playing embed ─────────────────────────────────────────────────
|
||||||
async def _send_icy_now_playing(self, guild_id: int, station_name: str, title: str) -> None:
|
async def _send_icy_now_playing(self, guild_id: int, station_name: str, title: str) -> None:
|
||||||
|
|||||||
+54
-27
@@ -9,21 +9,11 @@ from urllib.parse import urlparse
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_TITLE_RE = re.compile(r"StreamTitle='([^']*)';")
|
_TITLE_RE = re.compile(r"StreamTitle='([^']*)';")
|
||||||
|
|
||||||
# Some servers send a "StreamUrl='...'" which we can ignore
|
|
||||||
_CHUNK = 8192
|
_CHUNK = 8192
|
||||||
|
|
||||||
|
|
||||||
class IcyMetadataPoller:
|
class IcyMetadataPoller:
|
||||||
"""Polls an Icecast/SHOUTcast stream for ICY metadata and fires a callback on title change.
|
"""Polls an Icecast/SHOUTcast stream for ICY metadata and fires a callback on title change."""
|
||||||
|
|
||||||
Usage:
|
|
||||||
poller = IcyMetadataPoller("http://...", callback)
|
|
||||||
task = asyncio.create_task(poller.run())
|
|
||||||
...
|
|
||||||
poller.stop()
|
|
||||||
task.cancel()
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -39,19 +29,20 @@ class IcyMetadataPoller:
|
|||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Main loop — connect, poll, reconnect on failure."""
|
"""Main loop — connect, poll, reconnect on failure."""
|
||||||
|
log.info("ICY poller started for %s", self._url)
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
try:
|
try:
|
||||||
await self._poll_loop()
|
await self._poll_loop()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug(f"ICY poller disconnected, reconnecting in 10s: {self._url}")
|
log.warning("ICY poller error for %s, retrying in 10s", self._url, exc_info=True)
|
||||||
# Back off before reconnecting
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._stop.wait(), timeout=10.0)
|
await asyncio.wait_for(self._stop.wait(), timeout=10.0)
|
||||||
return # stop() was called
|
return
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
log.info("ICY poller stopped for %s", self._url)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Signal the poller to stop (doesn't cancel the task)."""
|
"""Signal the poller to stop (doesn't cancel the task)."""
|
||||||
@@ -67,12 +58,9 @@ class IcyMetadataPoller:
|
|||||||
if parsed.query:
|
if parsed.query:
|
||||||
path += "?" + parsed.query
|
path += "?" + parsed.query
|
||||||
|
|
||||||
# TLS for HTTPS
|
|
||||||
ssl_ctx = ssl.create_default_context() if parsed.scheme == "https" else None
|
ssl_ctx = ssl.create_default_context() if parsed.scheme == "https" else None
|
||||||
|
|
||||||
reader, writer = await asyncio.open_connection(host, port, ssl=ssl_ctx)
|
reader, writer = await asyncio.open_connection(host, port, ssl=ssl_ctx)
|
||||||
try:
|
try:
|
||||||
# Send HTTP GET with Icy-MetaData header
|
|
||||||
request = (
|
request = (
|
||||||
f"GET {path} HTTP/1.0\r\n"
|
f"GET {path} HTTP/1.0\r\n"
|
||||||
f"Host: {host}\r\n"
|
f"Host: {host}\r\n"
|
||||||
@@ -83,18 +71,54 @@ class IcyMetadataPoller:
|
|||||||
writer.write(request.encode())
|
writer.write(request.encode())
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|
||||||
# Read status line + headers
|
# Read status line
|
||||||
headers = await self._read_headers(reader)
|
status_line = await reader.readline()
|
||||||
|
status_text = status_line.decode("utf-8", errors="replace").strip()
|
||||||
|
log.info("ICY %s → %s", self._url, status_text)
|
||||||
|
|
||||||
|
# Follow redirect (301/302)
|
||||||
|
redirect_count = 0
|
||||||
|
while "30" in status_text.split(" ")[:2] and redirect_count < 5:
|
||||||
|
headers, _ = await self._read_headers(reader)
|
||||||
|
location = headers.get("location", "")
|
||||||
|
writer.close()
|
||||||
|
if not location:
|
||||||
|
log.warning("ICY redirect without Location header: %s", self._url)
|
||||||
|
return
|
||||||
|
log.info("ICY redirect %s → %s", self._url, location)
|
||||||
|
parsed = urlparse(location)
|
||||||
|
host = parsed.hostname or host
|
||||||
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
path = (parsed.path or "/") + ("?" + parsed.query if parsed.query else "")
|
||||||
|
ssl_ctx = ssl.create_default_context() if parsed.scheme == "https" else None
|
||||||
|
reader, writer = await asyncio.open_connection(host, port, ssl=ssl_ctx)
|
||||||
|
# Re-send request
|
||||||
|
request2 = (
|
||||||
|
f"GET {path} HTTP/1.0\r\n"
|
||||||
|
f"Host: {host}\r\n"
|
||||||
|
f"Icy-MetaData: 1\r\n"
|
||||||
|
f"User-Agent: yuuka-radio/1.0\r\n"
|
||||||
|
f"\r\n"
|
||||||
|
)
|
||||||
|
writer.write(request2.encode())
|
||||||
|
await writer.drain()
|
||||||
|
status_line = await reader.readline()
|
||||||
|
status_text = status_line.decode("utf-8", errors="replace").strip()
|
||||||
|
redirect_count += 1
|
||||||
|
|
||||||
|
# Read remaining headers
|
||||||
|
headers, _ = await self._read_headers(reader)
|
||||||
metaint = int(headers.get("icy-metaint", 0))
|
metaint = int(headers.get("icy-metaint", 0))
|
||||||
if metaint <= 0:
|
if metaint <= 0:
|
||||||
log.debug(f"No ICY metadata on {self._url}")
|
log.warning("ICY no metaint for %s (status: %s, got %d headers)",
|
||||||
return # stream has no metadata support
|
self._url, status_text, len(headers))
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("ICY polling %s — metaint=%d", self._url, metaint)
|
||||||
|
|
||||||
# Poll: skip audio → read metadata length → parse title
|
# Poll: skip audio → read metadata length → parse title
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
await self._skip(reader, metaint)
|
await self._skip(reader, metaint)
|
||||||
|
|
||||||
length_byte = await reader.readexactly(1)
|
length_byte = await reader.readexactly(1)
|
||||||
length = length_byte[0] * 16
|
length = length_byte[0] * 16
|
||||||
if length > 0:
|
if length > 0:
|
||||||
@@ -102,10 +126,11 @@ class IcyMetadataPoller:
|
|||||||
title = self._extract_title(raw.decode("utf-8", errors="replace"))
|
title = self._extract_title(raw.decode("utf-8", errors="replace"))
|
||||||
if title and title != self._last_title:
|
if title and title != self._last_title:
|
||||||
self._last_title = title
|
self._last_title = title
|
||||||
|
log.info("ICY now playing: %s", title)
|
||||||
try:
|
try:
|
||||||
await self._on_title(title)
|
await self._on_title(title)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("ICY callback error", exc_info=True)
|
log.warning("ICY callback error", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
writer.close()
|
writer.close()
|
||||||
try:
|
try:
|
||||||
@@ -114,20 +139,22 @@ class IcyMetadataPoller:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _read_headers(reader: asyncio.StreamReader) -> dict[str, str]:
|
async def _read_headers(reader: asyncio.StreamReader) -> tuple[dict[str, str], str]:
|
||||||
"""Read HTTP response headers into a lowercase-keyed dict."""
|
"""Read headers into a lowercase-keyed dict. Returns (headers, raw_text)."""
|
||||||
headers: dict[str, str] = {}
|
headers: dict[str, str] = {}
|
||||||
|
raw = ""
|
||||||
while True:
|
while True:
|
||||||
line = await reader.readline()
|
line = await reader.readline()
|
||||||
if not line:
|
if not line:
|
||||||
break
|
break
|
||||||
text = line.decode("utf-8", errors="replace").strip()
|
text = line.decode("utf-8", errors="replace").strip()
|
||||||
|
raw += text + "\n"
|
||||||
if not text:
|
if not text:
|
||||||
break # empty line = end of headers
|
break
|
||||||
if ":" in text:
|
if ":" in text:
|
||||||
key, _, value = text.partition(":")
|
key, _, value = text.partition(":")
|
||||||
headers[key.strip().lower()] = value.strip()
|
headers[key.strip().lower()] = value.strip()
|
||||||
return headers
|
return headers, raw
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _skip(reader: asyncio.StreamReader, n: int) -> None:
|
async def _skip(reader: asyncio.StreamReader, n: int) -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user