icy metadata support for 30 radios added
This commit is contained in:
1 parent
a59ffac93c
commit
4aecc81d2a
7 files changed
+202
-1
No files matched your search
@@ -10,7 +10,6 @@ cogs/archived/
|
|||||||
music/__pycache__/music.cpython-312.pyc
|
music/__pycache__/music.cpython-312.pyc
|
||||||
yuuka-radio-streams.md
|
yuuka-radio-streams.md
|
||||||
esef_learns.md
|
esef_learns.md
|
||||||
icy_supported.json
|
|
||||||
icy_metadata_radios.md
|
icy_metadata_radios.md
|
||||||
__pycache__/
|
__pycache__/
|
||||||
ssh_update.py
|
ssh_update.py
|
||||||
|
|||||||
@@ -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"]]
|
||||||
@@ -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._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._music_channels: dict[int, int] = {} # guild_id → text channel id (for now-playing announcements)
|
||||||
self._locks: dict[int, asyncio.Lock] = {}
|
self._locks: dict[int, asyncio.Lock] = {}
|
||||||
|
self._icy_tasks: dict[int, asyncio.Task] = {} # guild_id → ICY metadata poller task
|
||||||
|
|
||||||
# Load radio stations
|
# Load radio stations
|
||||||
stations_path = Path(__file__).parent / "radio_stations.json"
|
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"
|
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) ──────────────────────────
|
# ── shared helpers (used by all mixins via self) ──────────────────────────
|
||||||
def state(self, guild_id: int) -> GuildState:
|
def state(self, guild_id: int) -> GuildState:
|
||||||
if guild_id not in self._states:
|
if guild_id not in self._states:
|
||||||
@@ -72,6 +80,13 @@ class Music(PlayerCommands, QueueCommands, MusicEvents, RadioCommands, RadioEven
|
|||||||
return False
|
return False
|
||||||
return event_track.identifier == st.current.identifier
|
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 ────────────────────────────────────────────────────────────────
|
# ── !music ────────────────────────────────────────────────────────────────
|
||||||
@commands.command()
|
@commands.command()
|
||||||
async def music(self, ctx: commands.Context):
|
async def music(self, ctx: commands.Context):
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class PlayerCommands:
|
|||||||
self._radio_channels.pop(ctx.guild.id, None)
|
self._radio_channels.pop(ctx.guild.id, None)
|
||||||
self._music_channels.pop(ctx.guild.id, None)
|
self._music_channels.pop(ctx.guild.id, None)
|
||||||
self._locks.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."
|
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))
|
await ctx.reply(embed=discord.Embed(description=msg, color=C_OK))
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class MusicEvents:
|
|||||||
self._locks.pop(guild_id, None)
|
self._locks.pop(guild_id, None)
|
||||||
self._radio_channels.pop(guild_id, None)
|
self._radio_channels.pop(guild_id, None)
|
||||||
self._music_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)
|
await self._clear_channel_status(guild_id)
|
||||||
|
|
||||||
# ── track end → advance queue ─────────────────────────────────────────────
|
# ── track end → advance queue ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import wavelink
|
import wavelink
|
||||||
import discord
|
import discord
|
||||||
import logging
|
import logging
|
||||||
@@ -7,6 +8,7 @@ from ..shared.constants import (
|
|||||||
LOOP_OFF, MODE_MUSIC, MODE_RADIO,
|
LOOP_OFF, MODE_MUSIC, MODE_RADIO,
|
||||||
)
|
)
|
||||||
from .views import _CategoryView
|
from .views import _CategoryView
|
||||||
|
from .icy import IcyMetadataPoller
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -154,9 +156,42 @@ class RadioCommands:
|
|||||||
embed.set_footer(text="yuuka radio · use !stop to disconnect")
|
embed.set_footer(text="yuuka radio · use !stop to disconnect")
|
||||||
await interaction.followup.send(embed=embed)
|
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 ───────────────────────────────────────────────
|
# ── radio drop notification ───────────────────────────────────────────────
|
||||||
async def _notify_radio_drop(self, guild_id: int):
|
async def _notify_radio_drop(self, guild_id: int):
|
||||||
"""Send a notification to the channel where radio was started."""
|
"""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)
|
channel_id = self._radio_channels.pop(guild_id, None)
|
||||||
if channel_id is None:
|
if channel_id is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user