Add radio mode with two-step interactive picker, mode-aware !stop, concurrency locks

This commit is contained in:
esef committed 2026-07-05 18:36:53 +03:00
1 parent 97c244ad51
commit 83383e74e0
2 files changed
+381 -67

No files matched your search

+326 -67
View File
@@ -3,6 +3,8 @@ import discord
import random import random
import asyncio import asyncio
import logging import logging
import json
from pathlib import Path
from collections import deque from collections import deque
from discord.ext import commands from discord.ext import commands
@@ -15,6 +17,7 @@ C_OK = 0x50FA7B # green – success
C_WARN = 0xFFB86C # orange – warnings / loop C_WARN = 0xFFB86C # orange – warnings / loop
C_ERR = 0xFF5555 # red – errors C_ERR = 0xFF5555 # red – errors
C_HIST = 0x6272A4 # muted – history C_HIST = 0x6272A4 # muted – history
C_RADIO = 0xFF79C6 # pink – radio
LOOP_OFF = 0 LOOP_OFF = 0
LOOP_TRACK = 1 LOOP_TRACK = 1
@@ -26,6 +29,9 @@ LOOP_LABEL = {
LOOP_QUEUE: "🔁 Queue", LOOP_QUEUE: "🔁 Queue",
} }
MODE_MUSIC = "music"
MODE_RADIO = "radio"
# ── per-guild state ────────────────────────────────────────────────────────── # ── per-guild state ──────────────────────────────────────────────────────────
class GuildState: class GuildState:
def __init__(self): def __init__(self):
@@ -34,6 +40,7 @@ class GuildState:
self.loop: int = LOOP_OFF self.loop: int = LOOP_OFF
self.autoplay: bool = False self.autoplay: bool = False
self.current: wavelink.Playable | None = None self.current: wavelink.Playable | None = None
self.mode: str | None = None
def push_history(self, track: wavelink.Playable): def push_history(self, track: wavelink.Playable):
self.history.insert(0, track) self.history.insert(0, track)
@@ -68,12 +75,115 @@ def is_dj():
return False return False
return commands.check(predicate) return commands.check(predicate)
# ── UI views ─────────────────────────────────────────────────────────────────
class _CategoryView(discord.ui.View):
"""Step 1: buttons for each radio category."""
def __init__(self, bot: commands.Bot, stations: dict, timeout: float = 120.0):
super().__init__(timeout=timeout)
self.bot = bot
self.stations = stations
self.message: discord.Message | None = None
for i, category in enumerate(stations.keys()):
# Truncate long category names for button labels (max 80 chars)
label = category[:80]
btn = discord.ui.Button(
label=label,
style=discord.ButtonStyle.primary,
row=i // 5, # 5 buttons per row
)
btn.callback = self._make_category_callback(category)
self.add_item(btn)
def _make_category_callback(self, category: str):
async def callback(interaction: discord.Interaction):
station_list = self.stations[category]
view = _StationSelect(self.bot, category, station_list, interaction, timeout=self.timeout)
embed = discord.Embed(
title=f"📻 {category}",
description="Pick a station from the dropdown below:",
color=C_RADIO,
)
await interaction.response.edit_message(embed=embed, view=view)
view.message = interaction.message
return callback
async def on_timeout(self):
if self.message:
try:
for child in self.children:
child.disabled = True
await self.message.edit(view=self)
except Exception:
pass
class _StationSelect(discord.ui.View):
"""Step 2: dropdown to pick a specific station."""
def __init__(self, bot: commands.Bot, category: str, stations: list[dict], interaction: discord.Interaction, timeout: float = 120.0):
super().__init__(timeout=timeout)
self.bot = bot
self.category = category
self.stations = stations
self._interaction = interaction
self.message: discord.Message | None = None
self._picked: dict | None = None
self._done = asyncio.Event()
options = []
for s in stations:
label = s["name"][:100] # Discord limit
desc = s["desc"][:100] if s["desc"] else "\u200b" # zero-width space if empty
options.append(discord.SelectOption(label=label, description=desc, value=s["name"]))
select = discord.ui.Select(
placeholder="Choose a station…",
options=options,
)
select.callback = self._on_select
self.add_item(select)
async def _on_select(self, interaction: discord.Interaction):
station_name = interaction.data["values"][0] # type: ignore
for s in self.stations:
if s["name"] == station_name:
self._picked = s
break
# Disable the dropdown immediately
for child in self.children:
child.disabled = True
await interaction.response.edit_message(view=self)
# Do the actual radio playback
cog = self.bot.get_cog("Music")
if cog and self._picked:
await cog._play_radio_stream(interaction, self.category, self._picked)
self._done.set()
self.stop()
async def on_timeout(self):
if self.message:
try:
for child in self.children:
child.disabled = True
await self.message.edit(view=self)
except Exception:
pass
# ── cog ────────────────────────────────────────────────────────────────────── # ── cog ──────────────────────────────────────────────────────────────────────
class Music(commands.Cog): class Music(commands.Cog):
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
self._states: dict[int, GuildState] = {} self._states: dict[int, GuildState] = {}
self._voice_channels: dict[int, int] = {} # guild_id → channel_id for disconnect cleanup self._voice_channels: dict[int, int] = {} # guild_id → channel_id for disconnect cleanup
self._locks: dict[int, asyncio.Lock] = {}
# Load radio stations
stations_path = Path(__file__).parent / "radio_stations.json"
with open(stations_path, "r", encoding="utf-8") as f:
self._radio_stations: dict[str, list[dict]] = json.load(f)
log.info(f"Loaded radio stations: {sum(len(v) for v in self._radio_stations.values())} stations in {len(self._radio_stations)} categories")
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:
@@ -87,14 +197,22 @@ class Music(commands.Cog):
return vc return vc
return None return None
def _lock(self, guild_id: int) -> asyncio.Lock:
"""Get or create a per-guild asyncio.Lock."""
if guild_id not in self._locks:
self._locks[guild_id] = asyncio.Lock()
return self._locks[guild_id]
# ── channel status ──────────────────────────────────────────────────────── # ── channel status ────────────────────────────────────────────────────────
async def _set_channel_status(self, player: wavelink.Player, track: wavelink.Playable | None): async def _set_channel_status(self, player: wavelink.Player, track: wavelink.Playable | None = None, *, title: str | None = None):
"""Set (or clear) the voice channel status to reflect the current track.""" """Set (or clear) the voice channel status. Pass *title* for radio; otherwise uses track.title."""
channel = player.channel channel = player.channel
if channel is None: if channel is None:
return return
try: try:
if track: if title is not None:
status = f"📻 {title}"[:500]
elif track is not None:
status = f"🎵 {track.title}"[:500] status = f"🎵 {track.title}"[:500]
else: else:
status = None status = None
@@ -141,6 +259,7 @@ class Music(commands.Cog):
st = self._states.pop(guild_id, None) st = self._states.pop(guild_id, None)
if st: if st:
st.queue.clear() st.queue.clear()
self._locks.pop(guild_id, None)
await self._clear_channel_status(guild_id) await self._clear_channel_status(guild_id)
# ── track end → advance queue ───────────────────────────────────────────── # ── track end → advance queue ─────────────────────────────────────────────
@@ -152,6 +271,12 @@ class Music(commands.Cog):
st = self.state(player.guild.id) st = self.state(player.guild.id)
# Radio mode: stream ended — just stop
if st.mode == MODE_RADIO:
st.mode = None
await self._set_channel_status(player)
return
if st.loop == LOOP_TRACK and st.current: if st.loop == LOOP_TRACK and st.current:
await player.play(st.current) await player.play(st.current)
await self._set_channel_status(player, st.current) await self._set_channel_status(player, st.current)
@@ -173,7 +298,8 @@ class Music(commands.Cog):
await player.set_autoplay(wavelink.AutoPlayMode.enabled) await player.set_autoplay(wavelink.AutoPlayMode.enabled)
else: else:
st.current = None st.current = None
await self._set_channel_status(player, None) st.mode = None
await self._set_channel_status(player)
# ── track start → update status ─────────────────────────────────────────── # ── track start → update status ───────────────────────────────────────────
@commands.Cog.listener() @commands.Cog.listener()
@@ -183,7 +309,9 @@ class Music(commands.Cog):
return return
st = self.state(player.guild.id) st = self.state(player.guild.id)
st.current = payload.track st.current = payload.track
await self._set_channel_status(player, st.current) # Only auto-update status for music mode (radio sets its own title)
if st.mode != MODE_RADIO:
await self._set_channel_status(player, st.current)
# ── !play ───────────────────────────────────────────────────────────────── # ── !play ─────────────────────────────────────────────────────────────────
@commands.command(aliases=["p"]) @commands.command(aliases=["p"])
@@ -193,79 +321,89 @@ class Music(commands.Cog):
return await ctx.reply(embed=discord.Embed( return await ctx.reply(embed=discord.Embed(
description="❌ Join a voice channel first.", color=C_ERR)) description="❌ Join a voice channel first.", color=C_ERR))
vc = self._get_player(ctx)
if not vc:
vc = await ctx.author.voice.channel.connect(cls=wavelink.Player)
vc.autoplay = wavelink.AutoPlayMode.disabled
vc.inactive_timeout = None
# Track channel for disconnect cleanup
self._voice_channels[ctx.guild.id] = ctx.author.voice.channel.id
st = self.state(ctx.guild.id) st = self.state(ctx.guild.id)
async with ctx.typing(): # Mode check: refuse if radio is active
results = await wavelink.Playable.search(query) if st.mode == MODE_RADIO:
if not results:
return await ctx.reply(embed=discord.Embed( return await ctx.reply(embed=discord.Embed(
description="❌ Nothing found.", color=C_ERR)) description="📻 Radio is currently active. Use `!stop` first to switch back to music.",
color=C_WARN))
# playlist async with self._lock(ctx.guild.id):
if isinstance(results, wavelink.Playlist): vc = self._get_player(ctx)
tracks = list(results.tracks) if not vc:
for t in tracks: vc = await ctx.author.voice.channel.connect(cls=wavelink.Player)
t.extras = {"requester_id": ctx.author.id} vc.autoplay = wavelink.AutoPlayMode.disabled
st.queue.append(t) vc.inactive_timeout = None
self._voice_channels[ctx.guild.id] = ctx.author.voice.channel.id
# Start the first track if nothing is playing async with ctx.typing():
if not vc.playing and not vc.paused and st.queue: results = await wavelink.Playable.search(query)
st.current = st.queue.popleft()
await vc.play(st.current)
await self._set_channel_status(vc, st.current)
embed = discord.Embed( if not results:
title="📋 Playlist Queued", return await ctx.reply(embed=discord.Embed(
description=f"**{results.name}**", description="❌ Nothing found.", color=C_ERR))
color=C_QUEUE,
)
embed.add_field(name="Tracks", value=str(len(tracks)))
embed.add_field(name="Added by", value=ctx.author.mention)
await ctx.reply(embed=embed)
else: # playlist
track = results[0] if isinstance(results, wavelink.Playlist):
track.extras = {"requester_id": ctx.author.id} tracks = list(results.tracks)
for t in tracks:
t.extras = {"requester_id": ctx.author.id}
st.queue.append(t)
# Start the first track if nothing is playing
if not vc.playing and not vc.paused and st.queue:
st.current = st.queue.popleft()
await vc.play(st.current)
await self._set_channel_status(vc, st.current)
st.mode = MODE_MUSIC
if vc.playing or vc.paused:
st.queue.append(track)
embed = discord.Embed( embed = discord.Embed(
title="➕ Added to Queue", title="📋 Playlist Queued",
description=f"**{track.title}**", description=f"**{results.name}**",
color=C_QUEUE, color=C_QUEUE,
) )
embed.add_field(name="Duration", value=fmt_duration(track.length)) embed.add_field(name="Tracks", value=str(len(tracks)))
embed.add_field(name="Position", value=f"#{len(st.queue)}") embed.add_field(name="Added by", value=ctx.author.mention)
await ctx.reply(embed=embed)
else:
track = results[0]
track.extras = {"requester_id": ctx.author.id}
if vc.playing or vc.paused:
st.queue.append(track)
embed = discord.Embed(
title="➕ Added to Queue",
description=f"**{track.title}**",
color=C_QUEUE,
)
embed.add_field(name="Duration", value=fmt_duration(track.length))
embed.add_field(name="Position", value=f"#{len(st.queue)}")
embed.add_field(name="Requested by", value=ctx.author.mention)
if track.artwork:
embed.set_thumbnail(url=track.artwork)
return await ctx.reply(embed=embed)
st.current = track
await vc.play(track)
await self._set_channel_status(vc, track)
st.mode = MODE_MUSIC
embed = discord.Embed(
title="▶️ Now Playing",
description=f"**{track.title}**",
color=C_MAIN,
)
embed.add_field(name="Duration", value=fmt_duration(track.length))
embed.add_field(name="Requested by", value=ctx.author.mention) embed.add_field(name="Requested by", value=ctx.author.mention)
embed.add_field(name="Loop", value=LOOP_LABEL[st.loop], inline=True)
embed.add_field(name="Autoplay", value="On" if st.autoplay else "Off", inline=True)
if track.artwork: if track.artwork:
embed.set_thumbnail(url=track.artwork) embed.set_thumbnail(url=track.artwork)
return await ctx.reply(embed=embed) await ctx.reply(embed=embed)
st.current = track
await vc.play(track)
await self._set_channel_status(vc, track)
embed = discord.Embed(
title="▶️ Now Playing",
description=f"**{track.title}**",
color=C_MAIN,
)
embed.add_field(name="Duration", value=fmt_duration(track.length))
embed.add_field(name="Requested by", value=ctx.author.mention)
embed.add_field(name="Loop", value=LOOP_LABEL[st.loop], inline=True)
embed.add_field(name="Autoplay", value="On" if st.autoplay else "Off", inline=True)
if track.artwork:
embed.set_thumbnail(url=track.artwork)
await ctx.reply(embed=embed)
# ── !pause / !resume ────────────────────────────────────────────────────── # ── !pause / !resume ──────────────────────────────────────────────────────
@commands.command() @commands.command()
@@ -303,20 +441,29 @@ class Music(commands.Cog):
@commands.command(aliases=["leave", "disconnect"]) @commands.command(aliases=["leave", "disconnect"])
@is_dj() @is_dj()
async def stop(self, ctx: commands.Context): async def stop(self, ctx: commands.Context):
"""Stop playback, clear the queue, and disconnect from voice.""" """Stop playback (music or radio), clear the queue, and disconnect from voice."""
st = self.state(ctx.guild.id) st = self.state(ctx.guild.id)
current_mode = st.mode
st.queue.clear() st.queue.clear()
st.current = None st.current = None
st.loop = LOOP_OFF st.loop = LOOP_OFF
st.autoplay = False st.autoplay = False
st.mode = None
vc = self._get_player(ctx) vc = self._get_player(ctx)
if vc: if vc:
await self._set_channel_status(vc, None) await self._set_channel_status(vc)
await vc.disconnect() await vc.disconnect()
self._voice_channels.pop(ctx.guild.id, None) self._voice_channels.pop(ctx.guild.id, None)
await ctx.reply(embed=discord.Embed(description="⏹️ Stopped and disconnected.", color=C_OK)) self._locks.pop(ctx.guild.id, None)
if current_mode == MODE_RADIO:
msg = "⏹️ Stopped radio and disconnected."
else:
msg = "⏹️ Stopped and disconnected."
await ctx.reply(embed=discord.Embed(description=msg, color=C_OK))
# ── !seek ───────────────────────────────────────────────────────────────── # ── !seek ─────────────────────────────────────────────────────────────────
@commands.command() @commands.command()
@@ -516,6 +663,114 @@ class Music(commands.Cog):
) )
await ctx.reply(embed=embed) await ctx.reply(embed=embed)
# ── !radio ────────────────────────────────────────────────────────────────
@commands.command()
async def radio(self, ctx: commands.Context):
"""Browse and play radio stations with a two-step interactive picker."""
if not ctx.author.voice:
return await ctx.reply(embed=discord.Embed(
description="❌ Join a voice channel first.", color=C_ERR))
st = self.state(ctx.guild.id)
# Mode check: refuse if music is active
if st.mode == MODE_MUSIC and (st.current or st.queue):
return await ctx.reply(embed=discord.Embed(
description="🎵 Music is currently playing. Use `!stop` first to switch to radio.",
color=C_WARN))
# Step 1: show category buttons
view = _CategoryView(self.bot, self._radio_stations)
embed = discord.Embed(
title="📻 Radio — Choose a Category",
description="Select a genre to browse stations:",
color=C_RADIO,
)
await ctx.reply(embed=embed, view=view)
# ── radio playback helper (called from _StationSelect callback) ──────────
async def _play_radio_stream(self, interaction: discord.Interaction, category: str, station: dict):
"""Connect, play a radio stream, and set mode. Called from the station picker callback."""
guild = interaction.guild
if guild is None:
return
member = guild.get_member(interaction.user.id)
if member is None or member.voice is None:
await interaction.followup.send(
embed=discord.Embed(description="❌ You must be in a voice channel.", color=C_ERR),
ephemeral=True,
)
return
st = self.state(guild.id)
async with self._lock(guild.id):
# Connect or reuse existing player
vc = guild.voice_client
if not isinstance(vc, wavelink.Player):
try:
vc = await member.voice.channel.connect(cls=wavelink.Player)
except Exception as e:
await interaction.followup.send(
embed=discord.Embed(description=f"❌ Failed to connect: {e}", color=C_ERR),
ephemeral=True,
)
return
vc.autoplay = wavelink.AutoPlayMode.disabled
vc.inactive_timeout = None
self._voice_channels[guild.id] = member.voice.channel.id
# Stop anything currently playing
if vc.playing or vc.paused:
await vc.skip()
# Resolve and play the stream URL via Lavalink
try:
results = await wavelink.Playable.search(station["url"])
if not results:
await interaction.followup.send(
embed=discord.Embed(
description=f"❌ Could not resolve stream: `{station['url']}`\n"
"Lavalink may need the **LavaSrc** plugin for direct HTTP/Icecast URLs.",
color=C_ERR,
),
ephemeral=True,
)
return
track = results[0]
except Exception as e:
await interaction.followup.send(
embed=discord.Embed(description=f"❌ Failed to load stream: {e}", color=C_ERR),
ephemeral=True,
)
return
await vc.play(track)
# Set mode and clear any music state
st.queue.clear()
st.current = track
st.loop = LOOP_OFF
st.autoplay = False
st.mode = MODE_RADIO
# Set channel status with the JSON station name
await self._set_channel_status(vc, title=station["name"])
# Send now-playing embed
embed = discord.Embed(
title="📻 Now Playing",
description=f"### {station['name']}",
color=C_RADIO,
)
if station["desc"]:
embed.add_field(name="Description", value=station["desc"], inline=False)
embed.add_field(name="Category", value=category, inline=True)
embed.add_field(name="Source", value=station["url"], inline=True)
embed.set_footer(text="yuuka radio · use !stop to disconnect")
await interaction.followup.send(embed=embed)
# ── !music ──────────────────────────────────────────────────────────────── # ── !music ────────────────────────────────────────────────────────────────
@commands.command() @commands.command()
async def music(self, ctx: commands.Context): async def music(self, ctx: commands.Context):
@@ -527,6 +782,10 @@ class Music(commands.Cog):
"`!seek <1:30>` · `!looptrack` · `!loopqueue`\n" "`!seek <1:30>` · `!looptrack` · `!loopqueue`\n"
"`!autoplay`" "`!autoplay`"
), inline=False) ), inline=False)
embed.add_field(name="Radio", value=(
"`!radio` — Browse & play radio stations\n"
"Use `!stop` to stop either music or radio"
), inline=False)
embed.add_field(name="Queue", value=( embed.add_field(name="Queue", value=(
"`!queue` · `!q`\n" "`!queue` · `!q`\n"
"`!remove <pos>` · `!move <from> <to>`\n" "`!remove <pos>` · `!move <from> <to>`\n"
+55
View File
@@ -0,0 +1,55 @@
{
"Ambient, Lo-Fi & Chill (all reliable — genre is structurally beat-free/no-talk)": [
{"name": "9128", "desc": "Ambient/drone", "url": "https://streams.radio.co/s0aa1e6f4a/listen"},
{"name": "a.m. Ambient", "desc": "daytime ambient", "url": "http://radio.stereoscenic.com/ama-h"},
{"name": "Ambient Sleeping Pill", "desc": "beat-free sleep/focus", "url": "http://radio.stereoscenic.com/asp-h"},
{"name": "Ambient.FM", "desc": "solar-powered ambient", "url": "https://phoebe.streamerr.co:4140/ambient.mp3"},
{"name": "ChillTraxx", "desc": "downtempo/chillout", "url": "https://streamssleu.chilltrax.com/stream"},
{"name": "CodeRadio", "desc": "jazzy coding beats (freeCodeCamp)", "url": "https://coderadio-admin-v2.freecodecamp.org/listen/coderadio/radio.mp3"},
{"name": "Dinamo.FM - Sleep", "desc": "ambient/downtempo", "url": "http://channels.dinamo.fm/sleep-mp3"},
{"name": "Echoes of Bluemars - Cryosleep", "desc": "beatless drone", "url": "http://streams.echoesofbluemars.org:8000/cryosleep"},
{"name": "Moon Phase Radio - Ambient", "desc": "", "url": "https://cp12.serverse.com/proxy/moonphase/stream"},
{"name": "Moss Garden", "desc": "ambient + field recordings", "url": "https://radio.moss.garden/listen/moss_garden/radio.mp3"},
{"name": "Nordic Lodge", "desc": "downtempo/lounge", "url": "http://radio.streemlion.com:1160/stream"},
{"name": "NTS - Slow Focus", "desc": "drone/ambient/ragas", "url": "https://stream-mixtape-geo.ntslive.net/mixtape"},
{"name": "SomaFM - Drone Zone", "desc": "atmospheric/minimal beats", "url": "https://somafm.com/dronezone256.pls"},
{"name": "SomaFM - Groove Salad", "desc": "ambient/downtempo", "url": "https://somafm.com/groovesalad256.pls"}
],
"Electronic / Phonk / Synthwave / City Pop": [
{"name": "Bad Radio", "desc": "phonk/dark trap", "url": "http://server.badradio.biz:8000/stream"},
{"name": "Nightride FM", "desc": "synthwave/darksynth/chillsynth", "url": "https://stream.nightride.fm/nightride.mp3"},
{"name": "Nightwave Plaza", "desc": "vaporwave/future funk/city pop", "url": "https://radio.plaza.one/ogg"},
{"name": "NTS - Poolside", "desc": "Balearic/boogie/sophisti-pop", "url": "https://stream-mixtape-geo.ntslive.net/mixtape4"},
{"name": "Poolsuite FM", "desc": "disco/yacht rock/Balearic", "url": "https://s5.radio.co/sc9cb59935/listen"},
{"name": "Dub Ninja", "desc": "dub techno/ambient for focus", "url": "https://dub.ninja/live"},
{"name": "Rekt Network", "desc": "DnB/dubstep/dark techno", "url": "https://stream.rekt.network/hls/rekt/aac_hifi.m3u8"},
{"name": "Limbik Frequencies", "desc": "bass-heavy experimental", "url": "https://limbikfreq.com/listen/limbik_frequencies/320.mp3"}
],
"Video Game, Chiptune & Soundtracks (all automated by nature)": [
{"name": "CVGM", "desc": "video game/demoscene/computer music", "url": "https://slacker.cvgm.net/cvgm192"},
{"name": "Gensokyo Radio", "desc": "Touhou fan music", "url": "https://stream.gensokyoradio.net/3"},
{"name": "Gyusyabu", "desc": "retro Japanese PC game music", "url": "http://gyusyabu.ddo.jp:8000/;stream.mp3"},
{"name": "Kohina", "desc": "8-bit/16-bit computer music", "url": "https://kohina.duckdns.org/icecast/stream.ogg"},
{"name": "No Life Radio", "desc": "game music, 8-bit to modern", "url": "https://listen.nolife-radio.com/stream"},
{"name": "RadioSEGA", "desc": "Sega game music/remixes", "url": "http://content.radiosega.net:8006/live"},
{"name": "Slay Radio", "desc": "C64 remixes", "url": "http://relay4.slayradio.org:8300/"},
{"name": "Streaming Soundtracks", "desc": "movie/TV/anime scores", "url": "http://hi5.streamingsoundtracks.com/;"}
],
"Anime / J-Pop / Asian": [
{"name": "listen.moe", "desc": "anime & J-Pop", "url": "https://listen.moe/stream"},
{"name": "J-Club Bandstand - Jazz Sakura", "desc": "Japanese jazz", "url": "https://kathy.torontocast.com:3330/;"},
{"name": "AshiyaRadio", "desc": "jazz/bossa nova, Kobe", "url": "https://s3.radio.co/sc8d895604/listen"},
{"name": "J1 Radio - Gold", "desc": "Japanese pop/enka", "url": "http://gold.j1fm.tokyo/"}
],
"Jazz (mostly automated rotation, low talk risk)": [
{"name": "FIP Jazz", "desc": "", "url": "https://icecast.radiofrance.fr/fipjazz-hifi.aac"},
{"name": "The Jazz Groove", "desc": "laid-back/vocal jazz", "url": "http://east-mp3-128.streamthejazzgroove.com/stream"},
{"name": "Jazz24", "desc": "jazz/blues/funk/Latin", "url": "https://knkx-live-a.edge.audiocdn.com/6285_256k"},
{"name": "SomaFM - Sonic Universe", "desc": "avant-garde jazz", "url": "https://somafm.com/sonicuniverse256.pls"}
],
"Classical (automated catalog stations — skip ones mentioning \"live hosts\")": [
{"name": "Classical California", "desc": "", "url": "https://playerservices.streamtheworld.com/api/livestream-redirect/KDFCFMAAC.aac"},
{"name": "Linn Classical", "desc": "audiophile classical", "url": "http://radio.linn.co.uk:8004/stream"},
{"name": "WQXR Q2", "desc": "contemporary/experimental classical", "url": "http://q2stream.wqxr.org/q2"}
]
}