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

+6 -3
View File
@@ -2,11 +2,12 @@ from __future__ import annotations
import os
os.environ["SOMAFM_IMAGES"] = "0"
os.environ["BEEFY_IMAGES"] = "0"
import pytest
from somafm_tui import api, config
from beefy_player import config
from beefy_player.providers import base
@pytest.fixture(autouse=True)
@@ -25,6 +26,8 @@ def isolated_state(tmp_path, monkeypatch):
("CONFIG_PATH", config_dir / "config.json"),
("CHANNELS_CACHE", cache_dir / "channels.json"),
("PLAYLIST_CACHE", cache_dir / "playlists.json"),
("ARTWORK_CACHE_DIR", cache_dir / "artwork"),
("LEGACY_CONFIG_PATH", tmp_path / "legacy" / "config.json"),
):
monkeypatch.setattr(config, attr, value)
@@ -33,6 +36,6 @@ def isolated_state(tmp_path, monkeypatch):
path.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(config, "ensure_dirs", ensure_dirs)
monkeypatch.setattr(api, "_PLAYLIST_CACHE", None)
base.reset_playlist_cache()
ensure_dirs()
yield
+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
+39 -15
View File
@@ -6,22 +6,31 @@ from datetime import datetime, timedelta, timezone
import pytest
from somafm_tui import config
from somafm_tui.api import Song
from somafm_tui.app import SomaFMApp
from somafm_tui.widgets.help import HelpScreen
from somafm_tui.widgets.stage import StagePanel
from somafm_tui.widgets.station_list import StationList
from beefy_player import config
from beefy_player.app import BeefyPlayerApp
from beefy_player.models import Song
from beefy_player.widgets.help import HelpScreen
from beefy_player.widgets.stage import StagePanel
from beefy_player.widgets.station_list import StationList
def rendered_text(app: SomaFMApp) -> str:
def _history_method(func):
"""Adapt a ``(client, station_id)`` fake to the provider method signature."""
async def method(self, client, station_id):
return await func(client, station_id)
return method
def rendered_text(app: BeefyPlayerApp) -> str:
svg = html.unescape(app.export_screenshot()).replace("\xa0", " ")
return re.sub(r"<[^>]+>", " ", svg)
@pytest.mark.asyncio
async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False)
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
@@ -92,7 +101,7 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
@pytest.mark.asyncio
async def test_stage_renders_history_and_listener_count() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False)
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
station = app.current
@@ -123,9 +132,22 @@ async def test_stage_renders_history_and_listener_count() -> None:
assert needle in text
@pytest.mark.asyncio
async def test_splash_names_the_provider() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 32)) as pilot:
await pilot.pause()
app.current = None
app.query_one(StagePanel).refresh_stage()
await pilot.pause()
text = rendered_text(app)
assert "SOMA FM" in text
assert "somafm.com/channels.json" in text
@pytest.mark.asyncio
async def test_narrow_layout() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False)
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(64, 20)) as pilot:
await pilot.pause()
app.query_one(StagePanel).refresh_stage()
@@ -138,7 +160,7 @@ async def test_narrow_layout() -> None:
@pytest.mark.asyncio
async def test_stage_shows_bandcamp_link_only_when_present() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False)
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
station = app.current
@@ -162,7 +184,7 @@ async def test_stage_shows_bandcamp_link_only_when_present() -> None:
@pytest.mark.asyncio
async def test_bandcamp_link_survives_history_refresh(monkeypatch) -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False)
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
station = app.current
@@ -187,7 +209,9 @@ async def test_bandcamp_link_survives_history_refresh(monkeypatch) -> None:
)
]
monkeypatch.setattr("somafm_tui.app.api.fetch_history", fake_fetch_history)
monkeypatch.setattr(
type(app.provider), "fetch_history", _history_method(fake_fetch_history)
)
worker = app._fetch_history(station.id)
await worker.wait()
await pilot.pause()
@@ -200,7 +224,7 @@ async def test_bandcamp_link_survives_history_refresh(monkeypatch) -> None:
@pytest.mark.asyncio
async def test_cached_bandcamp_link_is_reapplied_to_new_song() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False)
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
station = app.current
@@ -219,7 +243,7 @@ async def test_cached_bandcamp_link_is_reapplied_to_new_song() -> None:
@pytest.mark.asyncio
async def test_media_session_commands_drive_the_app() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False)
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
# The status bar names whichever backend this platform uses.
+11 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from somafm_tui.__main__ import _build_parser, _list_stations
import pytest
from beefy_player.__main__ import _build_parser, _list_stations
def test_parser_defaults_and_flags() -> None:
@@ -19,6 +21,14 @@ def test_parser_defaults_and_flags() -> None:
assert args.autoplay is True
assert args.no_mpris is True
assert args.spectrum_source == "monitor"
assert args.provider is None # the config file decides
def test_provider_flag_is_validated() -> None:
parser = _build_parser()
assert parser.parse_args(["--provider", "somafm"]).provider == "somafm"
with pytest.raises(SystemExit):
parser.parse_args(["--provider", "no-such-catalogue"])
def test_list_stations_offline(capsys) -> None: