224 lines
7.1 KiB
Python
224 lines
7.1 KiB
Python
"""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",
|
|
}
|