icy metadata support for 30 radios added

This commit is contained in:
esef committed 2026-07-12 17:32:16 +03:00
1 parent a59ffac93c
commit 4aecc81d2a
7 files changed
+202 -1

No files matched your search

+15
View File
@@ -36,6 +36,7 @@ class Music(PlayerCommands, QueueCommands, MusicEvents, RadioCommands, RadioEven
self._radio_channels: dict[int, int] = {} # guild_id → text channel id (for drop notifications)
self._music_channels: dict[int, int] = {} # guild_id → text channel id (for now-playing announcements)
self._locks: dict[int, asyncio.Lock] = {}
self._icy_tasks: dict[int, asyncio.Task] = {} # guild_id → ICY metadata poller task
# Load radio stations
stations_path = Path(__file__).parent / "radio_stations.json"
@@ -47,6 +48,13 @@ class Music(PlayerCommands, QueueCommands, MusicEvents, RadioCommands, RadioEven
f"in {len(self._radio_stations)} categories"
)
# Load ICY-supported station URLs (for live track metadata)
icy_path = Path(__file__).parent / "../icy_supported.json"
with open(icy_path, "r", encoding="utf-8") as f:
icy_list: list[list[str]] = json.load(f)
self._icy_urls: set[str] = {item[1] for item in icy_list}
log.info(f"Loaded {len(self._icy_urls)} ICY-capable radio URLs")
# ── shared helpers (used by all mixins via self) ──────────────────────────
def state(self, guild_id: int) -> GuildState:
if guild_id not in self._states:
@@ -72,6 +80,13 @@ class Music(PlayerCommands, QueueCommands, MusicEvents, RadioCommands, RadioEven
return False
return event_track.identifier == st.current.identifier
def _cancel_icy_poller(self, guild_id: int) -> None:
"""Cancel and remove the ICY metadata poller for a guild, if any."""
task = self._icy_tasks.pop(guild_id, None)
if task is None:
return
task.cancel()
# ── !music ────────────────────────────────────────────────────────────────
@commands.command()
async def music(self, ctx: commands.Context):
+1
View File
@@ -166,6 +166,7 @@ class PlayerCommands:
self._radio_channels.pop(ctx.guild.id, None)
self._music_channels.pop(ctx.guild.id, None)
self._locks.pop(ctx.guild.id, None)
self._cancel_icy_poller(ctx.guild.id)
msg = "⏹️ Stopped radio and disconnected." if current_mode == MODE_RADIO else "⏹️ Stopped and disconnected."
await ctx.reply(embed=discord.Embed(description=msg, color=C_OK))
+1
View File
@@ -80,6 +80,7 @@ class MusicEvents:
self._locks.pop(guild_id, None)
self._radio_channels.pop(guild_id, None)
self._music_channels.pop(guild_id, None)
self._cancel_icy_poller(guild_id)
await self._clear_channel_status(guild_id)
# ── track end → advance queue ─────────────────────────────────────────────
+35
View File
@@ -1,3 +1,4 @@
import asyncio
import wavelink
import discord
import logging
@@ -7,6 +8,7 @@ from ..shared.constants import (
LOOP_OFF, MODE_MUSIC, MODE_RADIO,
)
from .views import _CategoryView
from .icy import IcyMetadataPoller
log = logging.getLogger(__name__)
@@ -154,9 +156,42 @@ class RadioCommands:
embed.set_footer(text="yuuka radio · use !stop to disconnect")
await interaction.followup.send(embed=embed)
# Spawn ICY metadata poller if this station supports it
if station["url"] in self._icy_urls:
self._cancel_icy_poller(guild.id)
poller = IcyMetadataPoller(
station["url"],
on_title=lambda title: self._send_icy_now_playing(
guild.id, station["name"], title
),
)
self._icy_tasks[guild.id] = asyncio.create_task(poller.run())
# ── ICY now-playing embed ─────────────────────────────────────────────────
async def _send_icy_now_playing(self, guild_id: int, station_name: str, title: str) -> None:
"""Post a compact 'Now Playing' embed when an ICY station updates its track."""
channel_id = self._radio_channels.get(guild_id)
if channel_id is None:
return
guild = self.bot.get_guild(guild_id)
if guild is None:
return
channel = guild.get_channel_or_thread(channel_id)
if channel is None:
return
try:
embed = discord.Embed(
description=f"🎵 **{title}**\n-# now on {station_name}",
color=C_RADIO,
)
await channel.send(embed=embed)
except Exception:
log.debug(f"Failed to send ICY now-playing in guild {guild_id}", exc_info=True)
# ── radio drop notification ───────────────────────────────────────────────
async def _notify_radio_drop(self, guild_id: int):
"""Send a notification to the channel where radio was started."""
self._cancel_icy_poller(guild_id)
channel_id = self._radio_channels.pop(guild_id, None)
if channel_id is None:
return
+149
View File
@@ -0,0 +1,149 @@
"""ICY (SHOUTcast/Icecast) metadata poller — reads live track titles from radio streams."""
import asyncio
import logging
import re
import ssl
from typing import Awaitable, Callable
from urllib.parse import urlparse
log = logging.getLogger(__name__)
_TITLE_RE = re.compile(r"StreamTitle='([^']*)';")
# Some servers send a "StreamUrl='...'" which we can ignore
_CHUNK = 8192
class IcyMetadataPoller:
"""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__(
self,
url: str,
on_title: Callable[[str], Awaitable[None]],
):
self._url = url
self._on_title = on_title
self._stop = asyncio.Event()
self._last_title: str | None = None
# ── public API ──────────────────────────────────────────────────────────
async def run(self) -> None:
"""Main loop — connect, poll, reconnect on failure."""
while not self._stop.is_set():
try:
await self._poll_loop()
except asyncio.CancelledError:
return
except Exception:
log.debug(f"ICY poller disconnected, reconnecting in 10s: {self._url}")
# Back off before reconnecting
try:
await asyncio.wait_for(self._stop.wait(), timeout=10.0)
return # stop() was called
except asyncio.TimeoutError:
pass
def stop(self) -> None:
"""Signal the poller to stop (doesn't cancel the task)."""
self._stop.set()
# ── internals ───────────────────────────────────────────────────────────
async def _poll_loop(self) -> None:
parsed = urlparse(self._url)
host = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "https" else 80)
path = parsed.path or "/"
if parsed.query:
path += "?" + parsed.query
# TLS for HTTPS
ssl_ctx = ssl.create_default_context() if parsed.scheme == "https" else None
reader, writer = await asyncio.open_connection(host, port, ssl=ssl_ctx)
try:
# Send HTTP GET with Icy-MetaData header
request = (
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(request.encode())
await writer.drain()
# Read status line + headers
headers = await self._read_headers(reader)
metaint = int(headers.get("icy-metaint", 0))
if metaint <= 0:
log.debug(f"No ICY metadata on {self._url}")
return # stream has no metadata support
# Poll: skip audio → read metadata length → parse title
while not self._stop.is_set():
await self._skip(reader, metaint)
length_byte = await reader.readexactly(1)
length = length_byte[0] * 16
if length > 0:
raw = await reader.readexactly(length)
title = self._extract_title(raw.decode("utf-8", errors="replace"))
if title and title != self._last_title:
self._last_title = title
try:
await self._on_title(title)
except Exception:
log.debug("ICY callback error", exc_info=True)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
@staticmethod
async def _read_headers(reader: asyncio.StreamReader) -> dict[str, str]:
"""Read HTTP response headers into a lowercase-keyed dict."""
headers: dict[str, str] = {}
while True:
line = await reader.readline()
if not line:
break
text = line.decode("utf-8", errors="replace").strip()
if not text:
break # empty line = end of headers
if ":" in text:
key, _, value = text.partition(":")
headers[key.strip().lower()] = value.strip()
return headers
@staticmethod
async def _skip(reader: asyncio.StreamReader, n: int) -> None:
"""Read and discard exactly *n* bytes from the stream."""
remaining = n
while remaining > 0:
chunk = await reader.read(min(remaining, _CHUNK))
if not chunk:
raise ConnectionError("ICY stream ended unexpectedly")
remaining -= len(chunk)
@staticmethod
def _extract_title(metadata: str) -> str | None:
"""Parse ``StreamTitle='Artist - Title';`` from an ICY metadata chunk."""
m = _TITLE_RE.search(metadata)
if not m:
return None
title = m.group(1).strip()
return title if title else None