"""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 dataclasses import dataclass from typing import Any from urllib.parse import quote import httpx from .. import config from ..models import Broadcast, Song, Station __all__ = [ "PLAYLIST_TTL", "RadioProvider", "StationPage", "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 @dataclass(slots=True) class StationPage: """One page of a large catalogue, in the catalogue's own order.""" stations: list[Station] #: Offset to request next, or ``None`` when the catalogue is exhausted. next_offset: int | None = 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/.json"``. history_source: str = "" #: ``User-Agent`` for this catalogue's requests (SomaFM likes to know). user_agent: str = "" # -- header hero ----------------------------------------------------- # #: Rows of header art drawn at the top left (rich markup is allowed). hero: tuple[str, ...] = () #: Paint :attr:`hero` with the app's spectrum gradient (a wordmark) instead #: of the colours the art carries itself (a picture). hero_gradient: bool = False #: Short lines printed beside the hero, top to bottom. hero_lines: tuple[str, ...] = () # -- left pane ------------------------------------------------------- # #: Show a broadcast schedule in the left pane instead of a station list. #: Set by single-stream catalogues, where a list of one station is a waste #: of the pane and "what is on, what is next" is the useful view. has_schedule: bool = False #: Look the current track up on Bandcamp and offer a link when it is found. #: Off for catalogues whose rotation is unlikely to be there (and for #: stations that would rather not be scraped). bandcamp: bool = True #: The catalogue can search its whole directory on the server. When true, #: the app calls :meth:`search_stations` as the filter changes instead of #: only filtering the (possibly small) list from :meth:`fetch_stations`. server_search: bool = False #: The catalogue is larger than one page. When true, the app fetches the #: first page with :meth:`fetch_station_page` and asks for the next one as #: the station list nears its end. paginated: bool = False #: Keep the catalogue's own ordering (popularity, schedule, ...) instead of #: sorting the station list alphabetically. preserve_order: bool = False # -- 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.""" async def search_stations( self, client: httpx.AsyncClient, query: str ) -> list[Station]: """Search the catalogue for *query*. Only called when :attr:`server_search` is true. Providers that search locally leave this alone; the app filters the loaded list itself. """ return [] async def fetch_station_page( self, client: httpx.AsyncClient, offset: int = 0 ) -> StationPage: """One page of a paginated catalogue. Only called when :attr:`paginated` is true. ``offset`` is the value from the previous page's :attr:`StationPage.next_offset`. """ return StationPage(stations=[], next_offset=None) async def fetch_stations_by_ids( self, client: httpx.AsyncClient, station_ids: list[str] ) -> list[Station]: """Fetch specific stations by id. Used for favourites that are not on the first page of a paginated catalogue. Providers that return their whole catalogue at once can leave this alone. """ return [] # -- 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 ``[]``.""" # -- schedule -------------------------------------------------------- # async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]: """Upcoming broadcasts, soonest first (the live one, if any, first).""" return [] def load_cached_schedule(self) -> list[Broadcast]: """The last good schedule from disk, or ``[]``.""" return [] # -- 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: """A playlist *we* resolve; HLS is handed to the player untouched.""" path = url.split("?", 1)[0].split("#", 1)[0].lower() return path.endswith((".pls", ".m3u")) 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}")