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

+13 -12
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import sys
import textwrap
from typing import TYPE_CHECKING, cast
from rich.console import Group
from rich.table import Table
@@ -14,6 +15,9 @@ from textual.widgets import Static
from .common import SPECTRUM_STOPS, gradient
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
HELP_ROWS: tuple[tuple[str, str], ...] = (
("↑ / ↓ j / k", "move through the station list"),
("enter", "tune in to the selected station"),
@@ -35,13 +39,6 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
("q", "quit"),
)
ABOUT_COMMON = (
(
"Stations and song history come from somafm.com "
"(channels.json + songs/<station>.json)."
),
)
ABOUT_LINUX = (
(
"The spectrum captures your actual PulseAudio/PipeWire output with "
@@ -50,7 +47,7 @@ ABOUT_LINUX = (
),
(
"The desktop picks us up over MPRIS as a "
"somafmtui media player — try `playerctl -l` / `playerctl metadata`."
"beefyplayer media player — try `playerctl -l` / `playerctl metadata`."
),
)
@@ -68,11 +65,15 @@ ABOUT_MACOS = (
),
)
ABOUT = ABOUT_COMMON + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
class HelpScreen(ModalScreen[None]):
def compose(self) -> ComposeResult:
provider = cast("BeefyPlayerApp", self.app).provider
about = (
f"Stations and song history come from {provider.name} "
f"({provider.station_source} + {provider.history_source}).",
) + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
table = Table.grid(padding=(0, 2), expand=False)
table.add_column(style="bold cyan", no_wrap=True, justify="right")
table.add_column(style="")
@@ -81,11 +82,11 @@ class HelpScreen(ModalScreen[None]):
header = Text()
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
header.append("SOMAFM TUI", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append("BEEFY PLAYER", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" keyboard & credits", style="dim")
footer = Text()
for paragraph in ABOUT:
for paragraph in about:
for line in textwrap.wrap(paragraph, width=70):
footer.append(line + "\n", style="dim")
+13 -10
View File
@@ -11,7 +11,7 @@ from textual.containers import Horizontal
from textual.css.query import NoMatches
from textual.widget import Widget
from ..api import Song, Station
from ..models import Song, Station
from .art import ArtPane
from .common import (
compact_count,
@@ -23,7 +23,7 @@ from .common import (
)
if TYPE_CHECKING:
from ..app import SomaFMApp
from ..app import BeefyPlayerApp
class StagePanel(Widget):
@@ -75,27 +75,30 @@ class StageInfo(Widget):
can_focus = False
def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app)
app = cast("BeefyPlayerApp", self.app)
width = max(8, self.size.width)
height = max(1, self.size.height)
station = app.current
if station is None:
return Group(*self._splash(width, height))
return Group(*self._splash(app, width, height))
details = self._details(app, station, station.now_playing, width)
return Group(*details[:height])
def _splash(self, width: int, height: int) -> list[Text]:
def _splash(
self, app: BeefyPlayerApp, width: int, height: int
) -> list[Text]:
provider = app.provider
lines = [
Text("◤ SOMA FM ◢", style="bold magenta"),
Text("listener-supported radio · san francisco", style="italic dim"),
Text(f"◤ {provider.brand} ◢", style="bold magenta"),
Text(provider.tagline, style="italic dim"),
Text(""),
Text("↑ ↓ choose a station", style=""),
Text("⏎ tune in", style=""),
Text("? all keyboard shortcuts", style=""),
Text(""),
Text("Stations load from somafm.com/channels.json", style="dim"),
Text(f"Stations load from {provider.station_source}", style="dim"),
]
return [
Text(truncate(line.plain, width), style=line.style)
@@ -104,7 +107,7 @@ class StageInfo(Widget):
def _details(
self,
app: SomaFMApp,
app: BeefyPlayerApp,
station: Station,
song: Song | None,
width: int,
@@ -188,7 +191,7 @@ class HistoryPane(Widget):
can_focus = False
def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app)
app = cast("BeefyPlayerApp", self.app)
width = max(8, self.size.width)
height = max(1, self.size.height)
station = app.current