diff --git a/somafm_tui/api.py b/somafm_tui/api.py index 61122c8..46b4dd5 100644 --- a/somafm_tui/api.py +++ b/somafm_tui/api.py @@ -56,6 +56,7 @@ class Song: artist: str = "" album: str = "" art_url: str = "" + bandcamp_url: str = "" played_at: datetime | None = None @property diff --git a/somafm_tui/app.py b/somafm_tui/app.py index a16f142..98fbd88 100644 --- a/somafm_tui/app.py +++ b/somafm_tui/app.py @@ -17,7 +17,7 @@ from textual.css.query import NoMatches from textual.theme import Theme from textual.widgets import Input -from . import api, artwork, config +from . import api, artwork, bandcamp, config from .api import Station from .fallback import fallback_channels from .mpris import MprisService @@ -133,6 +133,7 @@ class SomaFMApp(App[None]): self.artwork_url = "" self.artwork_bytes: bytes | None = None self._art_track_key = "" + self._bandcamp_key = "" self._http: httpx.AsyncClient | None = None self._closed = False self._tick_timer = None @@ -415,8 +416,51 @@ class SomaFMApp(App[None]): ) self.mpris.set_status(self._mpris_status()) self.mpris.set_volume(self.volume / 100.0) + self._update_bandcamp() self._update_artwork() + def _update_bandcamp(self) -> None: + """Find a Bandcamp track link once per now-playing track.""" + station = self.current + if station is None or self.offline or self._http is None: + return + song = station.now_playing + if song is None or not (song.artist.strip() or song.title.strip()): + return + + track_key = f"{station.id}|{song.key()}" + if track_key == self._bandcamp_key: + return + self._bandcamp_key = track_key + self._fetch_bandcamp( + station.id, song.key(), song.artist, song.album, song.title + ) + + @work(exclusive=True, group="bandcamp", exit_on_error=False) + async def _fetch_bandcamp( + self, + station_id: str, + song_key: str, + artist: str, + album: str, + title: str, + ) -> None: + if self._http is None or self.offline: + return + url = await bandcamp.find_track(self._http, artist, album, title) + if not url: + return + + station = next((s for s in self.stations if s.id == station_id), None) + if station is None or self.current is None or self.current.id != station_id: + return + song = station.now_playing + if song is None or song.key() != song_key: + return + song.bandcamp_url = url + with contextlib.suppress(NoMatches): + self.query_one(StagePanel).refresh_stage() + def _refresh_art(self) -> None: with contextlib.suppress(NoMatches): self.query_one(StagePanel).refresh_art() @@ -758,6 +802,7 @@ class SomaFMApp(App[None]): def action_refresh(self) -> None: self._last_history_fetch = 0.0 + self._bandcamp_key = "" self._set_notice("refreshing…", 2) if self.offline: self._set_notice("offline mode — nothing to refresh", 3) diff --git a/somafm_tui/widgets/stage.py b/somafm_tui/widgets/stage.py index d983f45..5f552ca 100644 --- a/somafm_tui/widgets/stage.py +++ b/somafm_tui/widgets/stage.py @@ -161,7 +161,7 @@ class StageInfo(Widget): notice = Text(truncate(app.notice, width), style="italic yellow") - return [ + details = [ heading, description, Text(""), @@ -170,8 +170,16 @@ class StageInfo(Widget): title, album, meta, - notice, ] + if song is not None and song.bandcamp_url: + link = Text(no_wrap=True) + link.append( + "Bandcamp ↗", + style=f"bold cyan link {song.bandcamp_url}", + ) + details.append(link) + details.append(notice) + return details class HistoryPane(Widget): diff --git a/tests/test_app.py b/tests/test_app.py index 4fd9955..e30c038 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -133,3 +133,27 @@ async def test_narrow_layout() -> None: text = rendered_text(app) assert "STATIONS" in text assert "ON AIR" in text + + +@pytest.mark.asyncio +async def test_stage_shows_bandcamp_link_only_when_present() -> None: + app = SomaFMApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(110, 40)) as pilot: + await pilot.pause() + station = app.current + assert station is not None + song = Song( + title="Rauschebart", + artist="Jens Buchert", + album="Gravity", + bandcamp_url="https://jensbuchert.bandcamp.com/track/rauschebart", + ) + station.now_playing = song + app.query_one(StagePanel).refresh_stage() + await pilot.pause() + assert "Bandcamp" in rendered_text(app) + + song.bandcamp_url = "" + app.query_one(StagePanel).refresh_stage() + await pilot.pause() + assert "Bandcamp" not in rendered_text(app)