Files
beefy-player/beefy_player/providers/base.py
T
beefcarver c4cc91ee1a feat(providers): add a pluggable station-catalogue seam
The app now holds one RadioProvider and asks it for stations, song history and stream URLs instead of importing SomaFM's HTTP client directly.

- models.py: provider-neutral Song/Station plus the shared parsers every catalogue needs (Station.provider/.uid keep ids unique once there is more than one).
- providers/base.py: the RadioProvider interface and the toolkit it needs in practice - httpx client factory, .pls/.m3u resolution with an 8h disk cache, and a default stream_candidates() strategy.
- providers/somafm/: everything SomaFM-specific (channels.json, songs/<id>.json, PLS layout, guessed icecast mirrors) behind SomaFMProvider.
- providers/__init__.py: registry, get_provider() and --provider, remembered in the config file.

Widgets render branding, splash copy and the help credits from the active provider, so a second catalogue needs no UI changes; with SomaFM selected the screen is byte-identical to before.
2026-11-03 16:10:30 -05:00

250 lines
8.0 KiB
Python

"""The provider seam: what a radio catalogue has to implement.
Beefy Player is an internet-radio player. Everything the UI needs from a
catalogue — the station list, the song history, the stream URLs — goes through
:class:`RadioProvider`, and the app only ever holds one of those. SomaFM
(:mod:`beefy_player.providers.somafm`) is the first implementation; adding
another station catalogue means writing another subclass and registering it in
:mod:`beefy_player.providers`, with no changes to the app or the widgets.
This module also carries the toolkit a provider needs in practice: an HTTP
client factory and playlist handling (``.pls``/``.m3u`` resolution with an
on-disk cache), which are the same for every catalogue.
"""
from __future__ import annotations
import asyncio
import re
import time
from abc import ABC, abstractmethod
from typing import Any
from urllib.parse import quote
import httpx
from .. import config
from ..models import Song, Station
__all__ = [
"PLAYLIST_TTL",
"RadioProvider",
"fetch_json",
"is_direct_stream",
"is_playlist",
"make_client",
"parse_pls",
"quote_station_id",
"reset_playlist_cache",
"resolve_playlist",
]
REQUEST_TIMEOUT = httpx.Timeout(15.0, connect=8.0)
PLAYLIST_TTL = 8 * 3600
_PLAYLIST_CACHE: dict[str, dict[str, Any]] | None = None
def make_client(user_agent: str = "") -> httpx.AsyncClient:
return httpx.AsyncClient(
headers={
"User-Agent": user_agent or config.USER_AGENT,
"Accept": "application/json, */*",
},
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
)
def reset_playlist_cache() -> None:
"""Forget the in-memory playlist cache (the tests use this)."""
global _PLAYLIST_CACHE
_PLAYLIST_CACHE = None
# --------------------------------------------------------------------------- #
# the interface
# --------------------------------------------------------------------------- #
class RadioProvider(ABC):
"""A station catalogue plus its streams and song history.
Providers are stateless singletons registered in
:data:`beefy_player.providers.PROVIDERS`; the app keeps one for its whole
lifetime and never instantiates another.
"""
#: Stable slug, used in caches, config and ``--provider``.
id: str = ""
#: Display name, e.g. ``"SomaFM"``.
name: str = ""
#: Bare domain, for docs and the help screen.
homepage: str = ""
#: Brand shown above the station list before anything is tuned in.
brand: str = ""
#: One-liner under the brand.
tagline: str = ""
#: Where stations come from, e.g. ``"somafm.com/channels.json"``.
station_source: str = ""
#: Where the song history comes from, e.g. ``"songs/<station>.json"``.
history_source: str = ""
#: ``User-Agent`` for this catalogue's requests (SomaFM likes to know).
user_agent: str = ""
# -- catalogue ------------------------------------------------------- #
@abstractmethod
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
"""Fetch the live station list and refresh the on-disk cache."""
@abstractmethod
def load_cached_stations(self) -> list[Station]:
"""The last good station list from disk, or ``[]``."""
@abstractmethod
def fallback_stations(self) -> list[Station]:
"""A bundled list, so a fresh offline start still works."""
# -- metadata -------------------------------------------------------- #
@abstractmethod
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
"""Recently played songs, newest first (falls back to the cache)."""
@abstractmethod
def load_cached_history(self, station_id: str) -> list[Song]:
"""The last good song history from disk, or ``[]``."""
# -- streams --------------------------------------------------------- #
def fallback_urls(self, station: Station) -> list[str]:
"""Guessed stream URLs, used when the catalogue is unreachable."""
return []
async def stream_candidates(
self, client: httpx.AsyncClient, station: Station
) -> list[str]:
"""Direct stream URLs to try, best first.
Resolves the station's own playlists (a couple of ``.pls`` files at
most) and appends :meth:`fallback_urls` in case none of them answer.
"""
urls: list[str] = []
playlists = station.sorted_playlists()
for entry in playlists[:3]:
url = entry.get("url", "")
resolved = await resolve_playlist(client, url)
for candidate in resolved:
if candidate not in urls:
urls.append(candidate)
if not resolved and url and url not in urls:
urls.append(url)
for entry in playlists[3:]:
url = entry.get("url", "")
if url and url not in urls:
urls.append(url)
for url in self.fallback_urls(station):
if url not in urls:
urls.append(url)
return urls[:10]
# --------------------------------------------------------------------------- #
# playlists
# --------------------------------------------------------------------------- #
def is_playlist(url: str) -> bool:
path = url.split("?", 1)[0].split("#", 1)[0].lower()
return path.endswith((".pls", ".m3u", ".m3u8"))
def is_direct_stream(url: str) -> bool:
return url.startswith(("http://", "https://")) and not is_playlist(url)
def parse_pls(text: str) -> list[str]:
urls: list[str] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
match = re.match(r"(?i)^file\d+\s*=\s*(\S+)$", line)
candidate = match.group(1) if match else line
if candidate.startswith(("http://", "https://")) and candidate not in urls:
urls.append(candidate)
return urls
def _playlist_cache() -> dict[str, dict[str, Any]]:
global _PLAYLIST_CACHE
if _PLAYLIST_CACHE is None:
raw = config.read_json(config.PLAYLIST_CACHE)
_PLAYLIST_CACHE = raw if isinstance(raw, dict) else {}
return _PLAYLIST_CACHE
async def resolve_playlist(client: httpx.AsyncClient, url: str) -> list[str]:
"""Turn a ``.pls`` URL into direct icecast URLs (cached for a few hours)."""
if not url:
return []
if not is_playlist(url):
return [url]
cache = _playlist_cache()
now = time.time()
entry = cache.get(url)
if isinstance(entry, dict):
fetched = entry.get("ts")
urls = entry.get("urls")
if (
isinstance(fetched, (int, float))
and now - fetched < PLAYLIST_TTL
and isinstance(urls, list)
):
cached_urls = [item for item in urls if isinstance(item, str)]
if cached_urls:
return cached_urls
try:
response = await client.get(url, headers={"Accept": "*/*"})
response.raise_for_status()
text = response.text
except httpx.HTTPError:
return []
urls = parse_pls(text)
if urls:
cache[url] = {"ts": now, "urls": urls}
config.write_json(config.PLAYLIST_CACHE, cache)
return urls
def quote_station_id(station_id: str) -> str:
"""Percent-encode a station id for use in a provider URL."""
return quote(station_id, safe="")
async def fetch_json(
client: httpx.AsyncClient, url: str, *, attempts: int = 2, pause: float = 0.6
) -> Any:
"""``GET url`` and decode JSON, retrying once: flaky mirrors are normal."""
last_error: Exception | None = None
for attempt in range(attempts):
try:
response = await client.get(url)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc: # includes JSON errors
last_error = exc
if attempt + 1 < attempts:
await asyncio.sleep(pause)
raise RuntimeError(f"could not fetch {url}: {last_error}")