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:
1 parent
e30ca09627
commit
c4cc91ee1a
13 files changed
+708
-372
No files matched your search
+39
-15
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user