From 4aecc81d2a23db5d4ff70ec4c3435412659a9326 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sun, 12 Jul 2026 17:32:16 +0300 Subject: [PATCH] icy metadata support for 30 radios added --- .gitignore | 1 - icy_supported.json | 1 + music/music_wrapper.py | 15 ++++ music/player/commands.py | 1 + music/player/events.py | 1 + music/radio/commands.py | 35 +++++++++ music/radio/icy.py | 149 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 icy_supported.json create mode 100644 music/radio/icy.py diff --git a/.gitignore b/.gitignore index 9430aa8..f40baa5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ cogs/archived/ music/__pycache__/music.cpython-312.pyc yuuka-radio-streams.md esef_learns.md -icy_supported.json icy_metadata_radios.md __pycache__/ ssh_update.py diff --git a/icy_supported.json b/icy_supported.json new file mode 100644 index 0000000..8de1f49 --- /dev/null +++ b/icy_supported.json @@ -0,0 +1 @@ +[["Dinamo.FM - Sleep", "http://channels.dinamo.fm/sleep-mp3"], ["a.m. Ambient", "http://radio.stereoscenic.com/ama-h"], ["Ambient Sleeping Pill", "http://radio.stereoscenic.com/asp-h"], ["Echoes of Bluemars - Cryosleep", "http://streams.echoesofbluemars.org:8000/cryosleep"], ["ChillTraxx", "https://streamssleu.chilltrax.com/stream"], ["Ambient.FM", "https://phoebe.streamerr.co:4140/ambient.mp3"], ["Moss Garden", "https://radio.moss.garden/listen/moss_garden/radio.mp3"], ["Nordic Lodge", "http://radio.streemlion.com:1160/stream"], ["Moon Phase Radio - Ambient", "https://cp12.serverse.com/proxy/moonphase/stream"], ["CodeRadio", "https://coderadio-admin-v2.freecodecamp.org/listen/coderadio/radio.mp3"], ["Nightride FM", "https://stream.nightride.fm/nightride.mp3"], ["9128", "https://streams.radio.co/s0aa1e6f4a/listen"], ["Bad Radio", "http://server.badradio.biz:8000/stream"], ["CVGM", "https://slacker.cvgm.net/cvgm192"], ["No Life Radio", "https://listen.nolife-radio.com/stream"], ["Gyusyabu", "http://gyusyabu.ddo.jp:8000/;stream.mp3"], ["RadioSEGA", "http://content.radiosega.net:8006/live"], ["NTS - Poolside", "https://stream-mixtape-geo.ntslive.net/mixtape4"], ["NTS - Slow Focus", "https://stream-mixtape-geo.ntslive.net/mixtape"], ["Slay Radio", "http://relay4.slayradio.org:8300/"], ["Gensokyo Radio", "https://stream.gensokyoradio.net/3"], ["AshiyaRadio", "https://s3.radio.co/sc8d895604/listen"], ["J-Club Bandstand - Jazz Sakura", "https://kathy.torontocast.com:3330/;"], ["J1 Radio - Gold", "http://gold.j1fm.tokyo/"], ["Linn Classical", "http://radio.linn.co.uk:8004/stream"], ["Limbik Frequencies", "https://limbikfreq.com/listen/limbik_frequencies/320.mp3"], ["Jazz24", "https://knkx-live-a.edge.audiocdn.com/6285_256k"], ["WQXR Q2", "http://q2stream.wqxr.org/q2"], ["Classical California", "https://playerservices.streamtheworld.com/api/livestream-redirect/KDFCFMAAC.aac"], ["Poolsuite FM", "https://s5.radio.co/sc9cb59935/listen"]] \ No newline at end of file diff --git a/music/music_wrapper.py b/music/music_wrapper.py index 1084759..fc4a3e7 100644 --- a/music/music_wrapper.py +++ b/music/music_wrapper.py @@ -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): diff --git a/music/player/commands.py b/music/player/commands.py index 8852e07..568fd53 100644 --- a/music/player/commands.py +++ b/music/player/commands.py @@ -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)) diff --git a/music/player/events.py b/music/player/events.py index f5824c8..a4b2afb 100644 --- a/music/player/events.py +++ b/music/player/events.py @@ -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 ───────────────────────────────────────────── diff --git a/music/radio/commands.py b/music/radio/commands.py index e3be220..1c996dd 100644 --- a/music/radio/commands.py +++ b/music/radio/commands.py @@ -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 diff --git a/music/radio/icy.py b/music/radio/icy.py new file mode 100644 index 0000000..2f940e0 --- /dev/null +++ b/music/radio/icy.py @@ -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