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.
This commit is contained in:
beefcarver committed 2026-11-03 16:10:30 -05:00
1 parent e30ca09627
commit c4cc91ee1a
13 files changed
+708 -372

No files matched your search

+18 -13
View File
@@ -3,7 +3,12 @@ from __future__ import annotations
import httpx
import pytest
from somafm_tui import api, config
from beefy_player import config
from beefy_player.models import parse_last_playing
from beefy_player.providers import base, get_provider
from beefy_player.providers.somafm import api
SOMAFM = get_provider("somafm")
CHANNELS_PAYLOAD = {
"channels": [
@@ -50,14 +55,14 @@ def test_parse_channels() -> None:
def test_parse_last_playing() -> None:
song = api.parse_last_playing("Jens Buchert - Rauschebart")
song = parse_last_playing("Jens Buchert - Rauschebart")
assert song is not None and song.artist == "Jens Buchert" and song.title == "Rauschebart"
en_dash = api.parse_last_playing("Tosca – Amber November")
en_dash = parse_last_playing("Tosca – Amber November")
assert en_dash is not None and en_dash.artist == "Tosca"
plain = api.parse_last_playing("Just A Title")
plain = parse_last_playing("Just A Title")
assert plain is not None and plain.title == "Just A Title" and plain.artist == ""
assert api.parse_last_playing("") is None
assert api.parse_last_playing(None) is None
assert parse_last_playing("") is None
assert parse_last_playing(None) is None
def test_parse_pls() -> None:
@@ -67,16 +72,16 @@ File1=https://ice2.somafm.com/groovesalad-256-mp3
File2=https://ice6.somafm.com/groovesalad-256-mp3
Version=2
"""
assert api.parse_pls(text) == [
assert base.parse_pls(text) == [
"https://ice2.somafm.com/groovesalad-256-mp3",
"https://ice6.somafm.com/groovesalad-256-mp3",
]
assert api.parse_pls("https://ice1.somafm.com/x-128-mp3\n# comment\n") == [
assert base.parse_pls("https://ice1.somafm.com/x-128-mp3\n# comment\n") == [
"https://ice1.somafm.com/x-128-mp3"
]
assert api.is_playlist("https://x/y.pls?foo=1") is True
assert api.is_direct_stream("https://ice1.somafm.com/x-128-mp3") is True
assert api.is_direct_stream("https://x/y.m3u") is False
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
def test_parse_songs_and_dates() -> None:
@@ -143,7 +148,7 @@ async def test_stream_candidates_with_mock_transport() -> None:
station = api.parse_channels(CHANNELS_PAYLOAD)[0]
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
urls = await api.stream_candidates(client, station)
urls = await SOMAFM.stream_candidates(client, station)
assert urls[:2] == [
"https://ice2.somafm.com/x-256-mp3",
@@ -152,7 +157,7 @@ async def test_stream_candidates_with_mock_transport() -> None:
assert len(requests) == 3 # three playlist URLs, cached by URL
# second run hits the playlist cache
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await api.stream_candidates(client, station)
await SOMAFM.stream_candidates(client, station)
assert len(requests) == 3