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")