feat(providers): add nine catalogues and the Radio Browser directory
This commit is contained in:
1 parent
a3ad693132
commit
49f7efe0d2
27 files changed
+1695
-22
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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") == []
|
||||
@@ -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") == []
|
||||
Reference in new issue
Block a user