From d1536666f7e073c9b55cbccbf44ddd08759c8efc Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 25 Jul 2026 12:57:39 +0300 Subject: [PATCH] radio: add Gensokyo Radio API poller for rich now-playing embeds --- music/radio/commands.py | 14 ++-- music/radio/gensokyo.py | 145 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 music/radio/gensokyo.py diff --git a/music/radio/commands.py b/music/radio/commands.py index 4ce5bf0..ea38b87 100644 --- a/music/radio/commands.py +++ b/music/radio/commands.py @@ -9,7 +9,7 @@ from ..shared.constants import ( ) from .views import _CategoryView from .icy import IcyMetadataPoller - +from . import gensokyo log = logging.getLogger(__name__) @@ -156,8 +156,14 @@ 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: + # Spawn gensokyo special metadata this snippet is for gensokyo radio only + if station["name"] == "Gensokyo Radio": + log.info("Starting Gensokyo API poller for %s", station["name"]) + self._cancel_icy_poller(guild.id) + self._icy_tasks[guild.id] = asyncio.create_task( + gensokyo.poll(self, guild.id, station["name"]) + ) + elif station["url"] in self._icy_urls: log.info("Starting ICY poller for %s", station["name"]) self._cancel_icy_poller(guild.id) poller = IcyMetadataPoller( @@ -168,7 +174,7 @@ class RadioCommands: ) 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)", + log.info("No metadata support for %s (%s not in ICY set of %d URLs)", station["name"], station["url"], len(self._icy_urls)) # ── ICY now-playing embed ───────────────────────────────────────────────── diff --git a/music/radio/gensokyo.py b/music/radio/gensokyo.py new file mode 100644 index 0000000..0590fce --- /dev/null +++ b/music/radio/gensokyo.py @@ -0,0 +1,145 @@ +"""Gensokyo Radio API — polls their REST endpoint for rich now-playing data.""" +import asyncio +import logging +from typing import Any + +import aiohttp +import discord + +from ..shared.constants import C_RADIO + +log = logging.getLogger(__name__) + +_API_URL = "https://gensokyoradio.net/api/station/playing/" +_ART_BASE = "https://gensokyoradio.net/images/albums/500" +_POLL_SECS = 15 + + +async def poll(cog: Any, guild_id: int, station_name: str) -> None: + """Poll Gensokyo Radio's API and post embeds on track change. + + ``cog`` is the Music cog instance (needed for bot, _radio_channels, etc.). + Stores itself in ``cog._icy_tasks`` so ``_cancel_icy_poller`` handles cleanup. + """ + last_song_id = None + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=10), + headers={"User-Agent": "yuuka-radio/1.0"}, + ) as session: + while True: + try: + async with session.get(_API_URL) as resp: + if resp.status != 200: + log.warning("Gensokyo API returned %d", resp.status) + await asyncio.sleep(_POLL_SECS) + continue + data = await resp.json() + + song_id = data["SONGDATA"]["SONGID"] + if song_id == last_song_id: + await asyncio.sleep(_POLL_SECS) + continue + last_song_id = song_id + + await _send_now_playing(cog, guild_id, station_name, data) + + except asyncio.CancelledError: + return + except Exception: + log.warning("Gensokyo API poller error", exc_info=True) + + await asyncio.sleep(_POLL_SECS) + + +async def _send_now_playing( + cog: Any, guild_id: int, station_name: str, data: dict +) -> None: + """Build and post a rich embed from Gensokyo Radio API JSON.""" + channel_id = cog._radio_channels.get(guild_id) + if channel_id is None: + return + guild = cog.bot.get_guild(guild_id) + if guild is None: + return + channel = guild.get_channel_or_thread(channel_id) + if channel is None: + return + + song = data.get("SONGINFO", {}) + times = data.get("SONGTIMES", {}) + sdata = data.get("SONGDATA", {}) + misc = data.get("MISC", {}) + server = data.get("SERVERINFO", {}) + + title = song.get("TITLE", "Unknown") + artist = song.get("ARTIST", "") + album = song.get("ALBUM", "") + year = song.get("YEAR", "") + circle = song.get("CIRCLE", "") + duration = times.get("DURATION", 0) + rating = sdata.get("RATING") + times_rated = sdata.get("TIMESRATED", 0) + listeners = server.get("LISTENERS", 0) + + # Bitrate — prefer highest quality stream + streams = server.get("STREAMS", {}) + bitrate = "" + for sid in ("3", "2", "1"): + s = streams.get(sid, {}) + if s.get("BITRATE"): + bitrate = str(s["BITRATE"]) + break + + # Album art + album_art = misc.get("ALBUMART", "") + album_art_url = f"{_ART_BASE}/{album_art}" if album_art else None + + # Build embed + desc_parts = [] + if artist: + desc_parts.append(f"**{artist}**") + if album: + album_str = album + if year: + album_str += f" ({year})" + desc_parts.append(album_str) + + embed = discord.Embed( + title=f"🎵 {title}", + description=" · ".join(desc_parts) if desc_parts else None, + color=C_RADIO, + ) + + if circle: + embed.add_field(name="Circle", value=circle, inline=True) + + if duration > 0: + mins, secs = divmod(duration, 60) + embed.add_field(name="Duration", value=f"{mins}:{secs:02d}", inline=True) + + if rating: + embed.add_field( + name="Rating", + value=f"{rating} ({times_rated} votes)", + inline=True, + ) + + if bitrate or listeners: + parts = [] + if bitrate: + parts.append(f"{bitrate} kbps") + if listeners: + parts.append(f"{listeners} listeners") + embed.add_field(name="Stream", value=" · ".join(parts), inline=True) + + if album_art_url: + embed.set_thumbnail(url=album_art_url) + + embed.set_footer(text=f"now on {station_name} · Gensokyo Radio") + + try: + await channel.send(embed=embed) + except Exception: + log.debug( + "Failed to send Gensokyo now-playing in guild %d", guild_id, exc_info=True + )