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

+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.