feat(providers): add nine catalogues and the Radio Browser directory

This commit is contained in:
beefcarver committed 2026-11-03 20:24:53 -05:00
1 parent a3ad693132
commit 49f7efe0d2
27 files changed
+1695 -22

No files matched your search

+3
View File
@@ -82,6 +82,9 @@ Version=2
assert base.is_playlist("https://x/y.pls?foo=1") is True
assert base.is_direct_stream("https://ice1.somafm.com/x-128-mp3") is True
assert base.is_direct_stream("https://x/y.m3u") is False
# HLS is the player's job, not ours: mpv reads the playlist itself
assert base.is_playlist("https://x/live/index.m3u8") is False
assert base.is_direct_stream("https://x/live/index.m3u8") is True
def test_parse_songs_and_dates() -> None:
+223
View File
@@ -0,0 +1,223 @@
"""The community-station providers: relays, status documents and schedules."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from beefy_player.providers import PROVIDERS
from beefy_player.providers.community import (
AnonradioProvider,
CashmereProvider,
DatafruitsProvider,
DublabProvider,
LotProvider,
LylProvider,
RefugeProvider,
RinseProvider,
dublab,
)
ICECAST = {
"icestats": {
"source": [
{
"listenurl": "https://anonradio.net:8443/anonradio",
"bitrate": 192,
"server_type": "audio/mpeg",
"listeners": 13,
"title": "repeating screwtape from 29-Nov-2023",
}
]
}
}
RADIO_CO = {
"status": "online",
"listeners": 41,
"current_track": {
"title": "Guest Show (r) - Ain Autavg",
"start_time": "2026-11-04T00:00:02+00:00",
"artwork_url": "https://images.radio.co/station.png",
},
}
def client_for(payload, status_code: int = 200) -> httpx.AsyncClient:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, json=payload)
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def test_every_community_provider_builds_a_playable_station() -> None:
providers = (
AnonradioProvider,
DatafruitsProvider,
CashmereProvider,
LylProvider,
LotProvider,
RefugeProvider,
DublabProvider,
RinseProvider,
)
for cls in providers:
provider = cls()
stations = provider.fallback_stations()
assert stations, cls.id
for station in stations:
assert station.provider == provider.id
assert station.playlists, (cls.id, station.id)
assert all(url.startswith(("http://", "https://")) for url in provider.fallback_urls(station))
# every one of them spells its name in the same font
assert len(provider.hero) == 3, cls.id
def test_rinse_is_a_family_of_three_stations() -> None:
stations = RinseProvider().fallback_stations()
assert [station.id for station in stations] == ["rinse_uk", "kool", "swu"]
assert [station.title for station in stations] == ["Rinse UK", "Kool London", "SWU"]
for station in stations:
assert "admin.stream.rinse.fm/proxy/" in station.playlists[0]["url"]
assert station.format_label.endswith("128k")
def test_the_lot_streams_hls_which_the_player_owns() -> None:
from beefy_player.providers.base import is_direct_stream, is_playlist
url = LotProvider().fallback_urls(LotProvider().station())[0]
assert url.endswith(".m3u8")
assert is_playlist(url) is False
assert is_direct_stream(url) is True
@pytest.mark.asyncio
async def test_icecast_status_gives_the_station_what_is_on() -> None:
provider = AnonradioProvider()
async with client_for(ICECAST) as client:
station = (await provider.fetch_stations(client))[0]
assert station.listeners == 13
assert station.now_playing is not None
assert station.now_playing.title == "repeating screwtape from 29-Nov-2023"
@pytest.mark.asyncio
async def test_radio_co_status_gives_a_track_and_its_art() -> None:
provider = RefugeProvider()
async with client_for(RADIO_CO) as client:
station = (await provider.fetch_stations(client))[0]
history = await provider.fetch_history(client, "refuge")
assert station.listeners == 41
assert station.now_playing is not None
assert station.now_playing.album == ""
assert station.now_playing.art_url.endswith("station.png")
assert history[0].title == "Ain Autavg"
# the show's own start time is used, not "just now"
assert history[0].played_at == datetime(
2026, 11, 4, 0, 0, 2, tzinfo=timezone.utc
)
@pytest.mark.asyncio
async def test_history_is_rolling_and_keeps_the_first_sighting() -> None:
provider = AnonradioProvider()
async with client_for(ICECAST) as client:
first = await provider.fetch_history(client, "anonradio")
again = await provider.fetch_history(client, "anonradio")
assert len(first) == 1
assert again[0].played_at == first[0].played_at
@pytest.mark.asyncio
async def test_a_station_without_metadata_still_carries_on() -> None:
"""Rinse, LYL, The Lot and Cashmere publish no status document."""
provider = RinseProvider()
async with client_for({}, status_code=500) as client:
stations = await provider.fetch_stations(client)
history = await provider.fetch_history(client, "rinse_uk")
assert len(stations) == 3
assert all(station.now_playing is None for station in stations)
assert history == []
def test_dublab_schedule_parses_the_site_calendar() -> None:
payload = [
{
"startTime": "2026-11-03T16:00:00-08:00",
"endTime": "2026-11-03T18:00:00-08:00",
"summary": "LIVE // Version Sounds",
},
{
"startTime": "2026-11-03T18:00:00-08:00",
"endTime": "2026-11-03T20:00:00-08:00",
"summary": "PRE-REC // Sounds of Now",
},
]
shows = dublab.parse_schedule(payload)
assert [show.name for show in shows] == [
"LIVE // Version Sounds",
"PRE-REC // Sounds of Now",
]
assert shows[0].start == datetime(
2026, 11, 3, 16, tzinfo=timezone(timedelta(hours=-8))
)
assert dublab.parse_schedule({"nope": True}) == []
def test_the_running_slot_is_marked_live() -> None:
provider = DublabProvider()
now = datetime.now(timezone.utc)
running = dublab.Broadcast(
name="LIVE // Version Sounds",
start=now - timedelta(minutes=30),
end=now + timedelta(minutes=30),
)
later = dublab.Broadcast(
name="Later Show",
start=now + timedelta(hours=1),
end=now + timedelta(hours=2),
)
marked = provider._mark_live([running, later])
assert [item.live for item in marked] == [True, False]
assert marked[0].key() == running.key()
@pytest.mark.asyncio
async def test_dublab_history_follows_the_schedule() -> None:
provider = DublabProvider()
now = datetime.now(timezone.utc)
payload = [
{
"startTime": (now - timedelta(minutes=20)).isoformat(),
"endTime": (now + timedelta(minutes=40)).isoformat(),
"summary": "LIVE // Version Sounds",
}
]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await provider.fetch_schedule(client)
history = await provider.fetch_history(client, "dublab")
assert history and history[0].title == "LIVE // Version Sounds"
def test_every_catalogue_is_registered_under_its_slug() -> None:
assert set(PROVIDERS) == {
"somafm",
"tilderadio",
"bassdrive",
"radioparadise",
"dublab",
"rinse",
"refuge",
"lyl",
"lot",
"radiobrowser",
"cashmere",
"datafruits",
"anonradio",
}
+23 -2
View File
@@ -22,7 +22,22 @@ def rendered_text(app) -> str:
def test_registry_lists_somafm_as_the_default() -> None:
assert available_providers() == ("somafm", "tilderadio", "bassdrive")
# the menu is alphabetical by name: aNONradio ... tilderadio
assert available_providers() == (
"anonradio",
"bassdrive",
"cashmere",
"datafruits",
"dublab",
"lyl",
"radiobrowser",
"radioparadise",
"refuge",
"rinse",
"somafm",
"lot",
"tilderadio",
)
assert isinstance(get_provider(), SomaFMProvider)
assert get_provider("somafm") is get_provider()
assert isinstance(get_provider("tilderadio"), TilderadioProvider)
@@ -177,7 +192,13 @@ async def test_p_opens_the_picker_and_switches_catalogue() -> None:
await pilot.pause()
assert isinstance(app.screen, ProviderScreen)
await pilot.press("2") # jump straight to tilderadio
# walk down the alphabetical menu from the current entry to tilderadio
slugs = available_providers()
steps = (slugs.index("tilderadio") - slugs.index("somafm")) % len(slugs)
for _ in range(steps):
await pilot.press("down")
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
await pilot.pause()
+113
View File
@@ -0,0 +1,113 @@
"""Radio Browser: a directory of stations, not a station."""
from __future__ import annotations
import httpx
import pytest
from beefy_player.providers.radiobrowser import RadioBrowserProvider, api, fallback
ENTRY = {
"stationuuid": "96062a7b-0601-11e8-ae97-52543be04c81",
"name": "France Info",
"url_resolved": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3",
"url": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3",
"country": "France",
"language": "french",
"tags": "news,talk,politic",
"codec": "MP3",
"bitrate": 128,
"favicon": "https://www.francetvinfo.fr/icon.png",
}
def test_an_entry_becomes_a_station() -> None:
station = api.station_from_entry(ENTRY)
assert station is not None
assert station.id == ENTRY["stationuuid"]
assert station.title == "France Info"
assert station.description == "France · french"
assert station.genre_label == "news · talk · politic"
assert station.image.endswith("icon.png")
assert station.format_label == "mp3 · 128k"
assert station.provider == "radiobrowser"
def test_entries_without_a_stream_or_a_name_are_skipped() -> None:
assert api.station_from_entry({**ENTRY, "url_resolved": "", "url": ""}) is None
assert api.station_from_entry({**ENTRY, "name": " "}) is None
assert api.station_from_entry({**ENTRY, "stationuuid": ""}) is None
assert api.station_from_entry("not a dict") is None
def test_parse_dedupes_and_sorts_by_name() -> None:
payload = [
{**ENTRY, "stationuuid": "b", "name": "Zed FM"},
{**ENTRY, "stationuuid": "a", "name": "Alpha Radio"},
{**ENTRY, "stationuuid": "a", "name": "Alpha Radio"},
]
stations = api.parse_stations(payload)
assert [station.title for station in stations] == ["Alpha Radio", "Zed FM"]
assert api.parse_stations(None) == []
assert api.parse_stations({}) == []
def test_hls_streams_survive_as_direct_urls() -> None:
hls = {
**ENTRY,
"stationuuid": "hls",
"url_resolved": "https://example.test/live/index.m3u8?token=1",
"codec": "AAC",
"bitrate": 64,
}
station = api.station_from_entry(hls)
assert station is not None
assert station.playlists[0]["format"] == "aac"
assert station.format_label == "aac · 64k"
@pytest.mark.asyncio
async def test_fetch_stations_caches_the_directory() -> None:
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
def handler(request: httpx.Request) -> httpx.Response:
assert "hidebroken=true" in str(request.url)
assert "limit=300" in str(request.url)
return httpx.Response(200, json=payload)
provider = RadioBrowserProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
stations = await provider.fetch_stations(client)
assert [station.title for station in stations] == ["Another", "France Info"]
assert [station.title for station in provider.load_cached_stations()] == [
"Another",
"France Info",
]
@pytest.mark.asyncio
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={})
provider = RadioBrowserProvider()
with pytest.raises(RuntimeError):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await provider.fetch_stations(client)
def test_the_bundled_directory_works_offline() -> None:
providers = fallback.fallback_stations()
assert len(providers) == 10
for station in providers:
assert station.playlists and station.provider == "radiobrowser"
assert station.playlists[0]["url"].startswith("https://")
def test_the_directory_offers_no_history_and_no_schedule() -> None:
provider = RadioBrowserProvider()
assert provider.has_schedule is False
assert provider.bandcamp is False
assert len(provider.hero) == 3
assert provider.hero_lines[0] == "RADIO BROWSER"
assert provider.load_cached_history("anything") == []
+90
View File
@@ -0,0 +1,90 @@
"""Radio Paradise: five mixes, one now-playing API, rolling history."""
from __future__ import annotations
import httpx
import pytest
from beefy_player.providers.radioparadise import RadioParadiseProvider, api
NOW = {
"time": 244,
"artist": "Eric Bibb",
"title": "Don't Ever Let Nobody Drag Your Spirit Down",
"album": "Painting Signs",
"year": "1999",
"cover": "https://img.radioparadise.com/covers/l/1.jpg",
}
def test_parse_now_keeps_the_cover_art() -> None:
song = api.parse_now(NOW)
assert song is not None
assert (song.artist, song.title) == ("Eric Bibb", NOW["title"])
assert song.album == "Painting Signs"
assert song.art_url.endswith("1.jpg")
assert api.parse_now({}) is None
assert api.parse_now(None) is None
def test_playlists_prefer_flac_and_carry_real_bitrates() -> None:
playlists = api.playlists(("flac", "aac-320", "mp3-320", "mp3-128"))
assert [entry["url"].rsplit("/", 1)[1] for entry in playlists] == [
"flac",
"aac-320",
"mp3-320",
"mp3-128",
]
assert [entry["quality"] for entry in playlists] == [
"highest",
"high",
"high",
"high",
]
assert playlists[0]["bitrate"] == "1411"
assert playlists[-1]["bitrate"] == "128"
def test_five_mixes_with_channel_numbers() -> None:
provider = RadioParadiseProvider()
stations = provider.fallback_stations()
assert [station.id for station in stations] == [
"main",
"mellow",
"rock",
"global",
"beyond",
]
assert api.CHANNEL_BY_STATION["beyond"] == 5
assert stations[0].format_label.startswith("flac")
# the alternates are FLAC-only relays
assert [entry["url"].rsplit("/", 1)[1] for entry in stations[1].playlists] == [
"mellow-flac"
]
@pytest.mark.asyncio
async def test_history_polls_the_right_channel_and_rolls() -> None:
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
return httpx.Response(200, json=NOW)
provider = RadioParadiseProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
first = await provider.fetch_history(client, "mellow")
again = await provider.fetch_history(client, "mellow")
assert any("chan=1" in url for url in seen)
assert len(first) == 1
assert again[0].key() == first[0].key()
assert again[0].played_at == first[0].played_at # first sighting kept
assert provider.load_cached_stations()[0].title == "The Main Mix"
@pytest.mark.asyncio
async def test_an_unknown_station_has_no_history() -> None:
provider = RadioParadiseProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json=NOW))) as client:
assert await provider.fetch_history(client, "no-such-mix") == []