282 lines
9.4 KiB
Python
282 lines
9.4 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 Broadcast, 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 = ""
|
|
|
|
# -- 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
|
|
|
|
# -- 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 ``[]``."""
|
|
|
|
# -- 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:
|
|
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}")
|