Add radio mode with two-step interactive picker, mode-aware !stop, concurrency locks
This commit is contained in:
1 parent
97c244ad51
commit
83383e74e0
2 files changed
+324
-10
No files matched your search
+269
-10
@@ -3,6 +3,8 @@ import discord
|
||||
import random
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from discord.ext import commands
|
||||
|
||||
@@ -15,6 +17,7 @@ C_OK = 0x50FA7B # green – success
|
||||
C_WARN = 0xFFB86C # orange – warnings / loop
|
||||
C_ERR = 0xFF5555 # red – errors
|
||||
C_HIST = 0x6272A4 # muted – history
|
||||
C_RADIO = 0xFF79C6 # pink – radio
|
||||
|
||||
LOOP_OFF = 0
|
||||
LOOP_TRACK = 1
|
||||
@@ -26,6 +29,9 @@ LOOP_LABEL = {
|
||||
LOOP_QUEUE: "🔁 Queue",
|
||||
}
|
||||
|
||||
MODE_MUSIC = "music"
|
||||
MODE_RADIO = "radio"
|
||||
|
||||
# ── per-guild state ──────────────────────────────────────────────────────────
|
||||
class GuildState:
|
||||
def __init__(self):
|
||||
@@ -34,6 +40,7 @@ class GuildState:
|
||||
self.loop: int = LOOP_OFF
|
||||
self.autoplay: bool = False
|
||||
self.current: wavelink.Playable | None = None
|
||||
self.mode: str | None = None
|
||||
|
||||
def push_history(self, track: wavelink.Playable):
|
||||
self.history.insert(0, track)
|
||||
@@ -68,12 +75,115 @@ def is_dj():
|
||||
return False
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
class Music(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self._states: dict[int, GuildState] = {}
|
||||
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:
|
||||
if guild_id not in self._states:
|
||||
@@ -87,14 +197,22 @@ class Music(commands.Cog):
|
||||
return vc
|
||||
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 ────────────────────────────────────────────────────────
|
||||
async def _set_channel_status(self, player: wavelink.Player, track: wavelink.Playable | None):
|
||||
"""Set (or clear) the voice channel status to reflect the current track."""
|
||||
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. Pass *title* for radio; otherwise uses track.title."""
|
||||
channel = player.channel
|
||||
if channel is None:
|
||||
return
|
||||
try:
|
||||
if track:
|
||||
if title is not None:
|
||||
status = f"📻 {title}"[:500]
|
||||
elif track is not None:
|
||||
status = f"🎵 {track.title}"[:500]
|
||||
else:
|
||||
status = None
|
||||
@@ -141,6 +259,7 @@ class Music(commands.Cog):
|
||||
st = self._states.pop(guild_id, None)
|
||||
if st:
|
||||
st.queue.clear()
|
||||
self._locks.pop(guild_id, None)
|
||||
await self._clear_channel_status(guild_id)
|
||||
|
||||
# ── track end → advance queue ─────────────────────────────────────────────
|
||||
@@ -152,6 +271,12 @@ class Music(commands.Cog):
|
||||
|
||||
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:
|
||||
await player.play(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)
|
||||
else:
|
||||
st.current = None
|
||||
await self._set_channel_status(player, None)
|
||||
st.mode = None
|
||||
await self._set_channel_status(player)
|
||||
|
||||
# ── track start → update status ───────────────────────────────────────────
|
||||
@commands.Cog.listener()
|
||||
@@ -183,6 +309,8 @@ class Music(commands.Cog):
|
||||
return
|
||||
st = self.state(player.guild.id)
|
||||
st.current = payload.track
|
||||
# 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 ─────────────────────────────────────────────────────────────────
|
||||
@@ -193,16 +321,22 @@ class Music(commands.Cog):
|
||||
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 radio is active
|
||||
if st.mode == MODE_RADIO:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="📻 Radio is currently active. Use `!stop` first to switch back to music.",
|
||||
color=C_WARN))
|
||||
|
||||
async with self._lock(ctx.guild.id):
|
||||
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)
|
||||
|
||||
async with ctx.typing():
|
||||
results = await wavelink.Playable.search(query)
|
||||
|
||||
@@ -223,6 +357,8 @@ class Music(commands.Cog):
|
||||
await vc.play(st.current)
|
||||
await self._set_channel_status(vc, st.current)
|
||||
|
||||
st.mode = MODE_MUSIC
|
||||
|
||||
embed = discord.Embed(
|
||||
title="📋 Playlist Queued",
|
||||
description=f"**{results.name}**",
|
||||
@@ -254,6 +390,8 @@ class Music(commands.Cog):
|
||||
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}**",
|
||||
@@ -303,20 +441,29 @@ class Music(commands.Cog):
|
||||
@commands.command(aliases=["leave", "disconnect"])
|
||||
@is_dj()
|
||||
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)
|
||||
current_mode = st.mode
|
||||
|
||||
st.queue.clear()
|
||||
st.current = None
|
||||
st.loop = LOOP_OFF
|
||||
st.autoplay = False
|
||||
st.mode = None
|
||||
|
||||
vc = self._get_player(ctx)
|
||||
if vc:
|
||||
await self._set_channel_status(vc, None)
|
||||
await self._set_channel_status(vc)
|
||||
await vc.disconnect()
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@@ -516,6 +663,114 @@ class Music(commands.Cog):
|
||||
)
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
async def music(self, ctx: commands.Context):
|
||||
@@ -527,6 +782,10 @@ class Music(commands.Cog):
|
||||
"`!seek <1:30>` · `!looptrack` · `!loopqueue`\n"
|
||||
"`!autoplay`"
|
||||
), 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=(
|
||||
"`!queue` · `!q`\n"
|
||||
"`!remove <pos>` · `!move <from> <to>`\n"
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user