From d468a626d8748b99ebb1e110dfcda43115c19960 Mon Sep 17 00:00:00 2001 From: Sushant Gangwani Date: Sat, 12 Sep 2026 19:52:58 -0400 Subject: [PATCH 1/4] initial commit --- .DS_Store | Bin 0 -> 6148 bytes CONTRIBUTING.md | 1 + docs/ROADMAP.md | 5 +- docs/architecture.md | 15 +- src/smorg/core/contract.py | 3 +- src/smorg/integrations/spotify/manifest.py | 25 +- src/smorg/integrations/spotify/panel.py | 321 +++++++++++++- src/smorg/integrations/spotify/pickers.py | 54 +++ src/smorg/integrations/spotify/source.py | 413 +++++++++++++++++- src/smorg/shell/app.py | 46 ++ src/smorg/shell/panel.py | 29 +- .../spotify/fixtures/spotify_search.json | 66 +++ tests/integrations/spotify/test_panel.py | 151 ++++++- tests/integrations/spotify/test_pickers.py | 60 +++ tests/integrations/spotify/test_source.py | 272 +++++++++++- 15 files changed, 1399 insertions(+), 62 deletions(-) create mode 100644 .DS_Store create mode 100644 src/smorg/integrations/spotify/pickers.py create mode 100644 tests/integrations/spotify/fixtures/spotify_search.json create mode 100644 tests/integrations/spotify/test_pickers.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5665e43f9c29d705ee54abfe472de808ca178d50 GIT binary patch literal 6148 zcmeH~Jx&8L5QS$*0xQv^Oz9p8DHW{~EhmUXjY1FSRhOnw2Z10E1U?DK{*X{avuiQctpk-_0ibe>Rzq9Qw9q6DXm%~8@W9xn zLTxJh6@zU${K@08YcaLy#QJ2c<4^v$ys)3BIcYd?HjO$61c5ezo^^+k|8Matbzbt@ zQ=%6Hg1|o`!2NnwPw`QnTYr3>J{ literal 0 HcmV?d00001 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 658081e..4e7d387 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,7 @@ An integration that outgrows one of these files can turn it into a package of th - `self.mark_seen(item)`: when an interaction should count as "seen". - `fetch_detail` (`SupportsDetail` protocol that's feature-detected by the shell): the details pane fetched and cached by the shell. Your panel never touches the network. - `fetch_phases` + `fetch_with_progress` (`SupportsProgress` protocol that's feature-detected by the shell): declared phase labels reported as each begins, shown by the refresh indicator and your panel's `show_fetch_phase` hook. +- `Panel.CredentialWorkRequested`: ask the shell to run `work(credentials, http)` off the UI thread (used by Spotify play/queue today; the future write-permission layer can wrap this). - `Action`s: validated against reserved and duplicated keybinds at construction, can be found in the `?` help listing. Action keys must still be bound in `panel.py` as `BINDINGS`, and the suite fails if one isn't: a key in the help listing that nothing binds is worse than no key at all. ## What development support you have diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 76e43fe..7957cd1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,12 +24,13 @@ ### Spotify -- `add to queue` and `play now` features (need write access) +- (none currently) ## General capabilities -- Restricted write permissions +- Restricted write permissions (Spotify REMOTE actions ship ahead of this; the + shell's credential-worker message is the seam to wrap) - Enable drop-in self-coded plugins diff --git a/docs/architecture.md b/docs/architecture.md index e88fcef..3618d03 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,11 @@ # Architecture (Written by John Clanker) smorg is a keyboard-driven terminal dashboard: each connected integration is a -tab, nothing is enabled by default, and the app is read-plus-safe-actions — it -shows what's on your plate and opens things, it never writes to a service. +tab, nothing is enabled by default, and the app is read-plus-safe-actions by +default — it shows what's on your plate and opens things. Writes to a service +are exceptional: today only Spotify's play/queue actions (`ActionClass.REMOTE`) +mutate remote state, via a shell-mediated credential worker that keeps panels +off the network. A general restricted-write permission model is still ahead. This document explains the load-bearing decisions. How to *add* an integration is covered in [CONTRIBUTING.md](../CONTRIBUTING.md). @@ -67,13 +70,11 @@ look like one: | | MCP transport | REST transport | | ---------------- | ------------- | -------------- | -| **OAuth** | Linear | — | +| **OAuth** | Linear | Spotify | | **Pasted token** | — | GitHub | -The empty corners are circumstance, not design. OAuth + REST is where any -classic OAuth provider with no token alternative lands (Spotify is the -roadmap's first candidate); token + MCP would be an MCP server reached with a -static bearer token. +The empty corners are circumstance, not design. Token + MCP would be an MCP +server reached with a static bearer token. ### The auth axis: OAuth where it is cheap, a pasted token where it is not diff --git a/src/smorg/core/contract.py b/src/smorg/core/contract.py index b839ff8..2c65f98 100644 --- a/src/smorg/core/contract.py +++ b/src/smorg/core/contract.py @@ -24,7 +24,8 @@ class ActionClass(StrEnum): LOCAL -> our own state LAUNCH -> browser or clipboard - REMOTE -> API (not implemented yet) + REMOTE -> API mutation (shell runs it via CredentialWorkRequested; a general + write-permission gate is still ahead) """ LOCAL = "local" diff --git a/src/smorg/integrations/spotify/manifest.py b/src/smorg/integrations/spotify/manifest.py index adaecd9..694e51c 100644 --- a/src/smorg/integrations/spotify/manifest.py +++ b/src/smorg/integrations/spotify/manifest.py @@ -1,5 +1,5 @@ -"""Spotify's declaration; connects with OAuth against an app the user creates themselves, and reads -the REST API. +"""Spotify's declaration; connects with OAuth against an app the user creates themselves, and talks +to the REST API (read plus play/queue writes). """ from __future__ import annotations @@ -28,6 +28,7 @@ "user-read-currently-playing", "user-read-playback-state", "user-read-recently-played", + "user-modify-playback-state", ), ) @@ -42,6 +43,26 @@ Action(id="open", label="Open in Spotify", key="o", action_class=ActionClass.LAUNCH), Action(id="play_now", label="Play now", key="p", action_class=ActionClass.REMOTE), Action(id="add_to_queue", label="Add to queue", key="a", action_class=ActionClass.REMOTE), + Action(id="toggle_shuffle", label="Shuffle", key="s", action_class=ActionClass.REMOTE), + Action(id="cycle_repeat", label="Repeat", key="e", action_class=ActionClass.REMOTE), + Action( + id="toggle_playback", + label="Play/pause", + key="space", + action_class=ActionClass.REMOTE, + ), + Action( + id="skip_previous", + label="Previous", + key="comma", + action_class=ActionClass.REMOTE, + ), + Action( + id="skip_next", + label="Next", + key="full_stop", + action_class=ActionClass.REMOTE, + ), ), ) diff --git a/src/smorg/integrations/spotify/panel.py b/src/smorg/integrations/spotify/panel.py index 5977bd8..393be21 100644 --- a/src/smorg/integrations/spotify/panel.py +++ b/src/smorg/integrations/spotify/panel.py @@ -3,19 +3,36 @@ from __future__ import annotations import webbrowser +from enum import StrEnum +from rich.cells import cell_len from rich.text import Text from textual import events from textual.app import ComposeResult from textual.binding import Binding from textual.widgets import Input +from smorg.integrations.spotify.pickers import search_picker from smorg.integrations.spotify.source import ( FALLBACK_URL, + Album, LastPlayed, NowPlaying, PlayerState, + Playlist, + SearchResults, Track, + next_repeat_mode, + pause_playback, + play_context, + play_track, + queue_track, + resume_playback, + search, + set_repeat, + set_shuffle, + skip_next, + skip_previous, ) from smorg.shell.format import age from smorg.shell.panel import Panel @@ -30,9 +47,16 @@ _QUEUE_DISPLAY_LIMIT = 10 # Aligns with the queue rows' title column: four number cells plus the two-cell gap. _ROW_INDENT = " " +# Separates transport columns; wide enough that adjacent glyphs never read as one label. +_CONTROL_GAP = " " -_PLAY_NOW_PLACEHOLDER = "play now — search (not implemented yet)" -_ADD_TO_QUEUE_PLACEHOLDER = "add to queue — search (not implemented yet)" +_PLAY_NOW_PLACEHOLDER = "play now — search" +_ADD_TO_QUEUE_PLACEHOLDER = "add to queue — search" + + +class _SearchAction(StrEnum): + PLAY = "play" + QUEUE = "queue" def _format_artists(artists: tuple[str, ...]) -> str: @@ -73,6 +97,46 @@ def _format_banner(now_playing: NowPlaying | None) -> list[Text]: return [banner, context] +def _padded(cell: str, width: int) -> str: + """cell widened to `width` terminal columns — emoji count as two, so ljust would under-pad.""" + return cell + " " * (width - cell_len(cell)) + + +def _format_controls(shuffle: bool, repeat: str, is_playing: bool | None) -> list[Text]: + """Transport bar: live state on the first line, each key hint under the glyph it drives.""" + if shuffle: + shuffle_glyph = "⇄ on" + else: + shuffle_glyph = "⇄ off" + if is_playing is True: + transport = "⏸" + elif is_playing is False: + transport = "▶" + else: + transport = "■" + if repeat == "track": + repeat_glyph = "🔁 track" + elif repeat == "context": + repeat_glyph = "🔁 context" + else: + repeat_glyph = "🔁 off" + + columns = ( + (shuffle_glyph, "s shuffle"), + ("⏮", ", prev"), + (transport, "space play/pause"), + ("⏭", ". next"), + (repeat_glyph, "e repeat"), + ) + state = Text(_ROW_INDENT[:2]) + hints = Text(_ROW_INDENT[:2], style=_DIM) + for glyph, hint in columns: + width = max(cell_len(glyph), cell_len(hint)) + state.append(_padded(glyph, width) + _CONTROL_GAP) + hints.append(_padded(hint, width) + _CONTROL_GAP) + return [Text(state.plain.rstrip()), Text(hints.plain.rstrip(), style=_DIM)] + + def _format_queue(queue: tuple[Track, ...]) -> list[Text]: lines = [Text(" up next", style=_DIM)] if not queue: @@ -111,14 +175,24 @@ class SpotifyPanel(Panel): Binding("o", "open", "open in Spotify", show=False), Binding("p", "play_now", "play now", show=False), Binding("a", "add_to_queue", "add to queue", show=False), + Binding("s", "toggle_shuffle", "shuffle", show=False), + Binding("e", "cycle_repeat", "repeat", show=False), + Binding("space", "toggle_playback", "play/pause", show=False), + Binding("comma", "skip_previous", "previous", show=False), + Binding("full_stop", "skip_next", "next", show=False), ] can_focus = True + def __init__(self) -> None: + super().__init__() + self._search_action: _SearchAction | None = None + self._work_pending = False + def compose(self) -> ComposeResult: yield from super().compose() - search = Input(id="player-search") - search.display = False - yield search + search_input = Input(id="player-search") + search_input.display = False + yield search_input def _state(self) -> PlayerState | None: if len(self.items) != 1: @@ -142,6 +216,12 @@ def render_ready(self) -> Text: lines.extend(_format_queue(state.queue)) lines.append(Text()) lines.extend(_format_last_played(state.last_played)) + lines.append(Text()) + if state.now_playing is None: + playing = None + else: + playing = state.now_playing.is_playing + lines.extend(_format_controls(state.shuffle, state.repeat, playing)) body = Text("\n").join(lines) # One row per track: a wrapped row spills into the next row's place and breaks the layout. body.no_wrap = True @@ -156,22 +236,125 @@ def action_open(self) -> None: webbrowser.open(state.url) def action_play_now(self) -> None: - self._open_search(_PLAY_NOW_PLACEHOLDER) + self._open_search(_SearchAction.PLAY, _PLAY_NOW_PLACEHOLDER) def action_add_to_queue(self) -> None: - self._open_search(_ADD_TO_QUEUE_PLACEHOLDER) + self._open_search(_SearchAction.QUEUE, _ADD_TO_QUEUE_PLACEHOLDER) + + def action_toggle_shuffle(self) -> None: + if self._work_pending: + return + state = self._state() + if state is None: + self.notify("nothing to control yet", severity="warning") + return + enabled = not state.shuffle + + def work(credentials, http): + return set_shuffle(credentials, http, enabled) + + if enabled: + verb = "shuffle on" + else: + verb = "shuffle off" + self._run_mode_change(work, verb) + + def action_cycle_repeat(self) -> None: + if self._work_pending: + return + state = self._state() + if state is None: + self.notify("nothing to control yet", severity="warning") + return + mode = next_repeat_mode(state.repeat) + + def work(credentials, http): + return set_repeat(credentials, http, mode) + + if mode == "off": + verb = "repeat off" + elif mode == "track": + verb = "repeat track" + else: + verb = "repeat context" + self._run_mode_change(work, verb) + + def action_toggle_playback(self) -> None: + if self._work_pending: + return + state = self._state() + if state is None: + self.notify("nothing to control yet", severity="warning") + return + if state.now_playing is not None and state.now_playing.is_playing: + verb = "paused" + + def pause_selected(credentials, http): + pause_playback(credentials, http) + return verb + + work = pause_selected + else: + verb = "playing" + + def resume_selected(credentials, http): + resume_playback(credentials, http) + return verb + + work = resume_selected + + self._run_mode_change(work, verb) + + def action_skip_next(self) -> None: + if self._work_pending: + return + + def work(credentials, http): + skip_next(credentials, http) + return "skipped" + + self._run_mode_change(work, "skipped next") + + def action_skip_previous(self) -> None: + if self._work_pending: + return - def _open_search(self, placeholder: str) -> None: - search = self.query_one("#player-search", Input) - search.placeholder = placeholder - search.value = "" - search.display = True - search.focus() + def work(credentials, http): + skip_previous(credentials, http) + return "skipped" + + self._run_mode_change(work, "skipped previous") + + def _run_mode_change(self, work, verb: str) -> None: + self._work_pending = True + self.post_message( + Panel.CredentialWorkRequested( + self, + work, + on_success=lambda _result: self._on_mode_succeeded(verb), + on_error=self._on_work_failed, + ) + ) + + def _on_mode_succeeded(self, verb: str) -> None: + self._work_pending = False + self.notify(verb) + + def _open_search(self, action: _SearchAction, placeholder: str) -> None: + if self._work_pending: + return + self._search_action = action + search_input = self.query_one("#player-search", Input) + search_input.placeholder = placeholder + search_input.value = "" + search_input.display = True + search_input.focus() def _close_search(self) -> None: - search = self.query_one("#player-search", Input) - search.display = False - search.value = "" + search_input = self.query_one("#player-search", Input) + search_input.display = False + search_input.value = "" + self._search_action = None self.focus() def on_key(self, event: events.Key) -> None: @@ -186,5 +369,109 @@ def on_key(self, event: events.Key) -> None: def on_input_submitted(self, event: Input.Submitted) -> None: event.stop() - self.notify("not implemented yet — coming with write permissions") + if self._work_pending: + return + action = self._search_action + query = event.value.strip() + if action is None: + self._close_search() + return + if not query: + self.notify("enter a search query", severity="warning") + return + include_contexts = action is _SearchAction.PLAY + + def work(credentials, http): + return search(credentials, http, query, tracks_only=not include_contexts) + + self._work_pending = True self._close_search() + self.post_message( + Panel.CredentialWorkRequested( + self, + work, + on_success=lambda result: self._on_search_ready(action, result), + on_error=self._on_work_failed, + refresh_on_success=False, + ) + ) + + def _on_search_ready(self, action: _SearchAction, result: object) -> None: + self._work_pending = False + if not isinstance(result, SearchResults): + self.notify("search failed", severity="error") + return + for_queue = action is _SearchAction.QUEUE + if for_queue: + selectable = result.tracks + else: + selectable = result.items + if not selectable: + self.notify("no matches", severity="warning") + return + picker = search_picker(result, for_queue=for_queue) + self.app.push_screen(picker, lambda chosen: self._on_pick(action, chosen)) + + def _on_pick(self, action: _SearchAction, chosen: object) -> None: + if chosen is None: + self.focus() + return + if isinstance(chosen, Track): + label = f"{chosen.track} · {_format_artists(chosen.artists)}" + track = chosen + if action is _SearchAction.PLAY: + verb = "playing" + + def play_selected(credentials, http): + play_track(credentials, http, track.uri) + return track + + work = play_selected + else: + verb = "queued" + + def queue_selected(credentials, http): + queue_track(credentials, http, track.uri) + return track + + work = queue_selected + elif isinstance(chosen, (Album, Playlist)): + if action is _SearchAction.QUEUE: + self.notify("only songs can be queued", severity="warning") + self.focus() + return + if isinstance(chosen, Album): + label = f"{chosen.name} · {_format_artists(chosen.artists)}" + else: + label = f"{chosen.name} · {chosen.owner}" + verb = "playing" + context = chosen + + def play_selected_context(credentials, http): + play_context(credentials, http, context.uri) + return context + + work = play_selected_context + else: + self.focus() + return + + self._work_pending = True + self.post_message( + Panel.CredentialWorkRequested( + self, + work, + on_success=lambda result: self._on_work_succeeded(verb, label, result), + on_error=self._on_work_failed, + ) + ) + + def _on_work_succeeded(self, verb: str, label: str, result: object) -> None: + self._work_pending = False + self.notify(f"{verb} {label}") + self.focus() + + def _on_work_failed(self, message: str) -> None: + self._work_pending = False + self.notify(message, severity="error") + self.focus() diff --git a/src/smorg/integrations/spotify/pickers.py b/src/smorg/integrations/spotify/pickers.py new file mode 100644 index 0000000..887ab62 --- /dev/null +++ b/src/smorg/integrations/spotify/pickers.py @@ -0,0 +1,54 @@ +"""Picker over Spotify search hits: one flat mix of songs, albums, and playlists.""" + +from __future__ import annotations + +from rich.text import Text +from textual.binding import Binding + +from smorg.integrations.spotify.source import Album, SearchItem, SearchResults, Track +from smorg.shell.picker import Picker, Row, Section + +_DIM = "dim" +_PLAY_HINT = "⏎ play · esc close" +_QUEUE_HINT = "⏎ add to queue · esc close" + + +class SearchPicker(Picker): + BINDINGS = [ + Binding("up", "cursor_up", "select", show=False), + Binding("down", "cursor_down", "select", show=False), + Binding("enter", "confirm", "choose", show=False), + Binding("escape", "close", "close", show=False), + ] + + +def _hit_row(item: SearchItem) -> Text: + if isinstance(item, Track): + row = Text(item.track) + row.append(f" · {', '.join(item.artists)}", style=_DIM) + row.append(" · song", style=_DIM) + return row + if isinstance(item, Album): + row = Text(item.name) + row.append(f" · {', '.join(item.artists)}", style=_DIM) + row.append(" · album", style=_DIM) + return row + row = Text(item.name) + row.append(f" · {item.owner}", style=_DIM) + row.append(" · playlist", style=_DIM) + return row + + +def search_picker(results: SearchResults, *, for_queue: bool) -> SearchPicker: + """One ungrouped list. Queue mode keeps songs only (contexts cannot be queued).""" + if for_queue: + items: tuple[SearchItem, ...] = results.tracks + hint = _QUEUE_HINT + title = "add to queue" + else: + items = results.items + hint = _PLAY_HINT + title = "play now" + rows: list[Row] = [(_hit_row(item), item) for item in items] + sections: list[Section] = [("", rows)] + return SearchPicker(title, sections, hint, 0) diff --git a/src/smorg/integrations/spotify/source.py b/src/smorg/integrations/spotify/source.py index 747fb08..7cdb0c4 100644 --- a/src/smorg/integrations/spotify/source.py +++ b/src/smorg/integrations/spotify/source.py @@ -1,5 +1,5 @@ """Fetch a snapshot of Spotify playback (now playing, the queue, and the last play) from the -REST API and map it to one typed player state. +REST API and map it to one typed player state. Also owns play/queue mutations. """ from __future__ import annotations @@ -25,10 +25,19 @@ PLAYER_ENDPOINT = "https://api.spotify.com/v1/me/player" QUEUE_ENDPOINT = "https://api.spotify.com/v1/me/player/queue" +PLAY_ENDPOINT = "https://api.spotify.com/v1/me/player/play" +PAUSE_ENDPOINT = "https://api.spotify.com/v1/me/player/pause" +NEXT_ENDPOINT = "https://api.spotify.com/v1/me/player/next" +PREVIOUS_ENDPOINT = "https://api.spotify.com/v1/me/player/previous" +SHUFFLE_ENDPOINT = "https://api.spotify.com/v1/me/player/shuffle" +REPEAT_ENDPOINT = "https://api.spotify.com/v1/me/player/repeat" RECENTLY_PLAYED_ENDPOINT = "https://api.spotify.com/v1/me/player/recently-played" PLAYLISTS_ENDPOINT = "https://api.spotify.com/v1/playlists" +SEARCH_ENDPOINT = "https://api.spotify.com/v1/search" LAST_PLAYED_LIMIT = 1 +SEARCH_LIMIT = 5 +REPEAT_MODES = ("off", "context", "track") # Where "o" opens when nothing is loaded on the player at all. FALLBACK_URL = "https://open.spotify.com" @@ -40,6 +49,37 @@ class Track: artists: tuple[str, ...] album: str url: str + uri: str + + +@dataclass(frozen=True) +class Playlist: + name: str + owner: str + url: str + uri: str + + +@dataclass(frozen=True) +class Album: + name: str + artists: tuple[str, ...] + url: str + uri: str + + +SearchItem = Track | Album | Playlist + + +@dataclass(frozen=True) +class SearchResults: + """A flat, Spotify-style mix of hits (not grouped by type).""" + + items: tuple[SearchItem, ...] + + @property + def tracks(self) -> tuple[Track, ...]: + return tuple(item for item in self.items if isinstance(item, Track)) @dataclass(frozen=True) @@ -65,16 +105,26 @@ class PlayerState(Item): now_playing: NowPlaying | None queue: tuple[Track, ...] last_played: LastPlayed | None + shuffle: bool + # "off" | "context" | "track" + repeat: str + + +@dataclass(frozen=True) +class _PlaybackSnapshot: + now_playing: NowPlaying | None + shuffle: bool + repeat: str def fetch(credentials: Credentials, http: httpx.Client) -> tuple[PlayerState, ...]: """The player's current snapshot: what's playing, what's queued, and what played last.""" - now_playing = _fetch_now_playing(credentials, http) + playback = _fetch_playback(credentials, http) queue = _fetch_queue(credentials, http) last_played = _fetch_last_played(credentials, http) - if now_playing is not None: - url = now_playing.track.url + if playback.now_playing is not None: + url = playback.now_playing.track.url else: url = FALLBACK_URL @@ -82,20 +132,304 @@ def fetch(credentials: Credentials, http: httpx.Client) -> tuple[PlayerState, .. id="player", updated_at=now(), url=url, - now_playing=now_playing, + now_playing=playback.now_playing, queue=queue, last_played=last_played, + shuffle=playback.shuffle, + repeat=playback.repeat, ) return (state,) -def _get( - credentials: Credentials, http: httpx.Client, url: str, params: dict[str, Any] | None = None +def next_repeat_mode(current: str) -> str: + """Cycle off → context → track → off.""" + try: + index = REPEAT_MODES.index(current) + except ValueError: + return "off" + return REPEAT_MODES[(index + 1) % len(REPEAT_MODES)] + + +def set_shuffle(credentials: Credentials, http: httpx.Client, enabled: bool) -> bool: + """Set shuffle on the active device; returns the requested state.""" + if enabled: + state = "true" + else: + state = "false" + response = _request( + credentials, + http, + "PUT", + SHUFFLE_ENDPOINT, + params={"state": state}, + ) + _require_mutation_ok(response) + return enabled + + +def set_repeat(credentials: Credentials, http: httpx.Client, mode: str) -> str: + """Set repeat on the active device; returns the requested mode.""" + if mode not in REPEAT_MODES: + raise Malformed(f"unknown repeat mode {mode!r}") + response = _request( + credentials, + http, + "PUT", + REPEAT_ENDPOINT, + params={"state": mode}, + ) + _require_mutation_ok(response) + return mode + + +def pause_playback(credentials: Credentials, http: httpx.Client) -> None: + """Pause the active device.""" + response = _request(credentials, http, "PUT", PAUSE_ENDPOINT) + _require_mutation_ok(response) + + +def resume_playback(credentials: Credentials, http: httpx.Client) -> None: + """Resume playback on the active device.""" + response = _request(credentials, http, "PUT", PLAY_ENDPOINT) + _require_mutation_ok(response) + + +def skip_next(credentials: Credentials, http: httpx.Client) -> None: + """Skip to the next track on the active device.""" + response = _request(credentials, http, "POST", NEXT_ENDPOINT) + _require_mutation_ok(response) + + +def skip_previous(credentials: Credentials, http: httpx.Client) -> None: + """Skip to the previous track on the active device.""" + response = _request(credentials, http, "POST", PREVIOUS_ENDPOINT) + _require_mutation_ok(response) + + +def search( + credentials: Credentials, + http: httpx.Client, + query: str, + *, + tracks_only: bool = False, +) -> SearchResults: + """Search hits matching query. Tracks only when queueing; otherwise songs, albums, and + playlists interleaved into one flat list. + """ + stripped = query.strip() + if not stripped: + raise Malformed("search query was empty") + if tracks_only: + type_param = "track" + else: + type_param = "track,album,playlist" + response = _request( + credentials, + http, + "GET", + SEARCH_ENDPOINT, + params={"q": stripped, "type": type_param, "limit": SEARCH_LIMIT}, + ) + _require_ok(response) + payload = _json_object(response) + tracks = _tracks_from_search(payload) + if tracks_only: + return SearchResults(items=tracks) + albums = _albums_from_search(payload) + playlists = _playlists_from_search(payload) + return SearchResults(items=_interleave(tracks, albums, playlists)) + + +def search_tracks(credentials: Credentials, http: httpx.Client, query: str) -> tuple[Track, ...]: + """Tracks matching query, best match first. An empty query raises Malformed.""" + return search(credentials, http, query, tracks_only=True).tracks + + +def _interleave(*groups: tuple[SearchItem, ...]) -> tuple[SearchItem, ...]: + """Round-robin across type buckets so the list feels mixed, not sectioned.""" + items: list[SearchItem] = [] + lengths = [len(group) for group in groups] + if lengths: + depth = max(lengths) + else: + depth = 0 + for index in range(depth): + for group in groups: + if index < len(group): + items.append(group[index]) + return tuple(items) + + +def play_track(credentials: Credentials, http: httpx.Client, uri: str) -> None: + """Start playing a track uri on the user's active device.""" + response = _request( + credentials, + http, + "PUT", + PLAY_ENDPOINT, + json_body={"uris": [uri]}, + ) + _require_mutation_ok(response) + + +def play_context(credentials: Credentials, http: httpx.Client, uri: str) -> None: + """Start playing a context uri (playlist, album, …) on the user's active device.""" + response = _request( + credentials, + http, + "PUT", + PLAY_ENDPOINT, + json_body={"context_uri": uri}, + ) + _require_mutation_ok(response) + + +def queue_track(credentials: Credentials, http: httpx.Client, uri: str) -> None: + """Append uri to the user's playback queue.""" + response = _request( + credentials, + http, + "POST", + QUEUE_ENDPOINT, + params={"uri": uri}, + ) + _require_mutation_ok(response) + + +def play_query(credentials: Credentials, http: httpx.Client, query: str) -> Track: + """Search for query and play the first track hit; returns that track.""" + track = _first_search_hit(credentials, http, query) + play_track(credentials, http, track.uri) + return track + + +def queue_query(credentials: Credentials, http: httpx.Client, query: str) -> Track: + """Search for query and queue the first track hit; returns that track.""" + track = _first_search_hit(credentials, http, query) + queue_track(credentials, http, track.uri) + return track + + +def _first_search_hit(credentials: Credentials, http: httpx.Client, query: str) -> Track: + tracks = search_tracks(credentials, http, query) + if not tracks: + raise Unavailable(f"no tracks matched {query.strip()!r}") + return tracks[0] + + +def _tracks_from_search(payload: dict[str, Any]) -> tuple[Track, ...]: + tracks_block = payload.get("tracks") + if tracks_block is None: + return () + if not isinstance(tracks_block, dict): + raise Malformed("'tracks' was missing or not an object") + raw_items = tracks_block.get("items") + if not isinstance(raw_items, list): + raise Malformed("'tracks.items' was missing or not a list") + tracks: list[Track] = [] + for raw in raw_items: + if raw is None: + continue + if not isinstance(raw, dict): + raise Malformed(f"a search hit was {type(raw).__name__}, expected an object") + tracks.append(_track_of(raw)) + return tuple(tracks) + + +def _playlists_from_search(payload: dict[str, Any]) -> tuple[Playlist, ...]: + playlists_block = payload.get("playlists") + if playlists_block is None: + return () + if not isinstance(playlists_block, dict): + raise Malformed("'playlists' was missing or not an object") + raw_items = playlists_block.get("items") + if not isinstance(raw_items, list): + raise Malformed("'playlists.items' was missing or not a list") + playlists: list[Playlist] = [] + for raw in raw_items: + if raw is None: + continue + if not isinstance(raw, dict): + raise Malformed(f"a playlist hit was {type(raw).__name__}, expected an object") + playlists.append(_playlist_of(raw)) + return tuple(playlists) + + +def _albums_from_search(payload: dict[str, Any]) -> tuple[Album, ...]: + albums_block = payload.get("albums") + if albums_block is None: + return () + if not isinstance(albums_block, dict): + raise Malformed("'albums' was missing or not an object") + raw_items = albums_block.get("items") + if not isinstance(raw_items, list): + raise Malformed("'albums.items' was missing or not a list") + albums: list[Album] = [] + for raw in raw_items: + if raw is None: + continue + if not isinstance(raw, dict): + raise Malformed(f"an album hit was {type(raw).__name__}, expected an object") + albums.append(_album_of(raw)) + return tuple(albums) + + +def _playlist_of(raw: dict[str, Any]) -> Playlist: + owner = raw.get("owner") + if not isinstance(owner, dict): + raise Malformed(f"'owner' was {type(owner).__name__}, expected an object") + return Playlist( + name=sanitize_line(required_string(raw, "name")), + owner=sanitize_line(required_string(owner, "display_name")), + url=_external_spotify_url(raw, "playlist"), + uri=_typed_uri(raw, "playlist"), + ) + + +def _album_of(raw: dict[str, Any]) -> Album: + return Album( + name=sanitize_line(required_string(raw, "name")), + artists=_artists_of(raw), + url=_external_spotify_url(raw, "album"), + uri=_typed_uri(raw, "album"), + ) + + +def _typed_uri(raw: dict[str, Any], kind: str) -> str: + prefix = f"spotify:{kind}:" + uri = raw.get("uri") + if isinstance(uri, str) and uri.startswith(prefix): + return uri + item_id = raw.get("id") + if isinstance(item_id, str) and item_id: + return f"{prefix}{item_id}" + raise Malformed(f"a {kind} had no usable uri or id") + + +def _external_spotify_url(raw: dict[str, Any], kind: str) -> str: + external_urls = raw.get("external_urls") + if not isinstance(external_urls, dict): + raise Malformed(f"'external_urls' was {type(external_urls).__name__}, expected an object") + url = required_string(external_urls, "spotify") + if urlsplit(url).scheme != "https": + raise Malformed(f"a {kind}'s Spotify url was not https") + return url + + +def _request( + credentials: Credentials, + http: httpx.Client, + method: str, + url: str, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, ) -> httpx.Response: try: - response = http.get( + response = http.request( + method, url, params=params or {}, + json=json_body, headers={"Authorization": f"Bearer {credentials.access_token}"}, ) except httpx.HTTPError as error: @@ -117,6 +451,14 @@ def _require_ok(response: httpx.Response) -> None: raise Unavailable(f"Spotify returned HTTP {response.status_code}") +def _require_mutation_ok(response: httpx.Response) -> None: + if response.status_code in {200, 204}: + return + if response.status_code == 404: + raise Unavailable("no active Spotify device; open Spotify on a phone or computer first") + raise Unavailable(f"Spotify returned HTTP {response.status_code}") + + def _json_object(response: httpx.Response) -> dict[str, Any]: try: payload = response.json() @@ -127,12 +469,22 @@ def _json_object(response: httpx.Response) -> dict[str, Any]: return payload -def _fetch_now_playing(credentials: Credentials, http: httpx.Client) -> NowPlaying | None: - response = _get(credentials, http, PLAYER_ENDPOINT) +def _fetch_playback(credentials: Credentials, http: httpx.Client) -> _PlaybackSnapshot: + response = _request(credentials, http, "GET", PLAYER_ENDPOINT) if response.status_code == 204: - return None + return _PlaybackSnapshot(now_playing=None, shuffle=False, repeat="off") _require_ok(response) payload = _json_object(response) + return _PlaybackSnapshot( + now_playing=_now_playing_of(payload, credentials, http), + shuffle=_shuffle_of(payload), + repeat=_repeat_of(payload), + ) + + +def _now_playing_of( + payload: dict[str, Any], credentials: Credentials, http: httpx.Client +) -> NowPlaying | None: item = payload.get("item") if item is None or payload.get("currently_playing_type") != "track": return None @@ -148,6 +500,20 @@ def _fetch_now_playing(credentials: Credentials, http: httpx.Client) -> NowPlayi ) +def _shuffle_of(payload: dict[str, Any]) -> bool: + value = payload.get("shuffle_state") + if isinstance(value, bool): + return value + return False + + +def _repeat_of(payload: dict[str, Any]) -> str: + value = payload.get("repeat_state") + if isinstance(value, str) and value in REPEAT_MODES: + return value + return "off" + + def _is_playing_of(payload: dict[str, Any]) -> bool: value = payload.get("is_playing") if isinstance(value, bool): @@ -184,8 +550,12 @@ def _playlist_name_of(uri: object, credentials: Credentials, http: httpx.Client) return None playlist_id = uri.rsplit(":", 1)[-1] try: - response = _get( - credentials, http, f"{PLAYLISTS_ENDPOINT}/{playlist_id}", params={"fields": "name"} + response = _request( + credentials, + http, + "GET", + f"{PLAYLISTS_ENDPOINT}/{playlist_id}", + params={"fields": "name"}, ) _require_ok(response) name = required_string(_json_object(response), "name") @@ -195,7 +565,7 @@ def _playlist_name_of(uri: object, credentials: Credentials, http: httpx.Client) def _fetch_queue(credentials: Credentials, http: httpx.Client) -> tuple[Track, ...]: - response = _get(credentials, http, QUEUE_ENDPOINT) + response = _request(credentials, http, "GET", QUEUE_ENDPOINT) _require_ok(response) payload = _json_object(response) raw_queue = payload.get("queue") @@ -210,8 +580,8 @@ def _fetch_queue(credentials: Credentials, http: httpx.Client) -> tuple[Track, . def _fetch_last_played(credentials: Credentials, http: httpx.Client) -> LastPlayed | None: - response = _get( - credentials, http, RECENTLY_PLAYED_ENDPOINT, params={"limit": LAST_PLAYED_LIMIT} + response = _request( + credentials, http, "GET", RECENTLY_PLAYED_ENDPOINT, params={"limit": LAST_PLAYED_LIMIT} ) _require_ok(response) payload = _json_object(response) @@ -235,9 +605,20 @@ def _track_of(raw: dict[str, Any]) -> Track: artists=_artists_of(raw), album=sanitize_line(_album_name(raw)), url=_track_url(raw), + uri=_track_uri(raw), ) +def _track_uri(track: dict[str, Any]) -> str: + uri = track.get("uri") + if isinstance(uri, str) and uri.startswith("spotify:track:"): + return uri + track_id = track.get("id") + if isinstance(track_id, str) and track_id: + return f"spotify:track:{track_id}" + raise Malformed("a track had no usable uri or id") + + def _track_url(track: dict[str, Any]) -> str: external_urls = track.get("external_urls") if not isinstance(external_urls, dict): diff --git a/src/smorg/shell/app.py b/src/smorg/shell/app.py index cd96cf6..48f6ebd 100644 --- a/src/smorg/shell/app.py +++ b/src/smorg/shell/app.py @@ -448,6 +448,52 @@ def on_panel_detail_requested(self, message: Panel.DetailRequested) -> None: if self.active_tab: self.fetch_detail(self.active_tab, message.panel, message.item) + def on_panel_credential_work_requested(self, message: Panel.CredentialWorkRequested) -> None: + if self.active_tab: + self.run_with_credentials(self.active_tab, message) + + @work(thread=True) + def run_with_credentials( + self, integration_id: str, message: Panel.CredentialWorkRequested + ) -> None: + """Resolve credentials for `integration_id` and run message.work off the UI thread.""" + try: + integration = get_integration(integration_id) + except UnknownIntegration: + self.call_from_thread(message.on_error, "not connected") + return + try: + path, client_id = resolve_connection( + integration.manifest, self._tab_configs.get(integration_id) + ) + except ValueError as error: + self.call_from_thread(message.on_error, str(error)) + return + try: + with httpx.Client(timeout=30) as http: + credentials = credentials_for(integration_id, path, client_id, http) + if credentials is None: + self.call_from_thread(message.on_error, "not connected") + return + result = message.work(credentials, http) + except (CredentialStoreError, IntegrationError) as error: + self.call_from_thread(message.on_error, _format_fetch_error(error, integration_id)) + return + self.call_from_thread(self._credential_work_succeeded, integration_id, message, result) + + def _credential_work_succeeded( + self, + integration_id: str, + message: Panel.CredentialWorkRequested, + result: object, + ) -> None: + message.on_success(result) + if not message.refresh_on_success: + return + panel = self._panel_of(integration_id) + if panel is not None: + self.refresh_tab(integration_id, panel, force=True) + @work(thread=True) def fetch_detail(self, integration_id: str, panel: Panel, item: Item) -> None: """Fetch one item's detail off the UI thread; results and errors land in the panel's diff --git a/src/smorg/shell/panel.py b/src/smorg/shell/panel.py index c354f26..2136877 100644 --- a/src/smorg/shell/panel.py +++ b/src/smorg/shell/panel.py @@ -10,7 +10,7 @@ from collections.abc import Callable, Iterable from datetime import datetime from enum import StrEnum -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from rich.console import RenderableType from rich.text import Text @@ -26,6 +26,11 @@ from smorg.shell.refresh_indicator import RefreshIndicator from smorg.shell.terminal_palette import StatusColors, status_colors, widget_background +if TYPE_CHECKING: + import httpx + + from smorg.auth.store import Credentials + class PanelState(StrEnum): LOADING = "loading" @@ -114,6 +119,28 @@ def __init__(self, panel: Panel, item: Item) -> None: self.panel = panel self.item = item + class CredentialWorkRequested(Message): + """Ask the shell to run `work(credentials, http)` off the UI thread. + + Plan B can wrap this with confirmations / opt-in write scopes; call sites stay the same. + """ + + def __init__( + self, + panel: Panel, + work: Callable[[Credentials, httpx.Client], object], + on_success: Callable[[object], None], + on_error: Callable[[str], None], + *, + refresh_on_success: bool = True, + ) -> None: + super().__init__() + self.panel = panel + self.work = work + self.on_success = on_success + self.on_error = on_error + self.refresh_on_success = refresh_on_success + DEFAULT_CSS = """ Panel > #body { height: 1fr; } """ diff --git a/tests/integrations/spotify/fixtures/spotify_search.json b/tests/integrations/spotify/fixtures/spotify_search.json new file mode 100644 index 0000000..fc2f4ff --- /dev/null +++ b/tests/integrations/spotify/fixtures/spotify_search.json @@ -0,0 +1,66 @@ +{ + "tracks": { + "href": "https://api.spotify.com/v1/search?query=brightside&type=track%2Calbum%2Cplaylist", + "items": [ + { + "id": "3n3Ppam7vgaVa1iaRUc9Lp", + "name": "Mr. Brightside", + "uri": "spotify:track:3n3Ppam7vgaVa1iaRUc9Lp", + "artists": [{ "id": "0C0XlULifJtAgn6ZNCW2eu", "name": "The Killers" }], + "album": { "id": "1XkGORuUX2QGOEIL4EbJKm", "name": "Hot Fuss" }, + "external_urls": { "spotify": "https://open.spotify.com/track/3n3Ppam7vgaVa1iaRUc9Lp" }, + "duration_ms": 222973, + "type": "track" + }, + { + "id": "0eGsygTp906u18L0Oimnem", + "name": "Take On Me", + "uri": "spotify:track:0eGsygTp906u18L0Oimnem", + "artists": [{ "id": "0OU8jZtsjE29Zk9nc754wp", "name": "a-ha" }], + "album": { "id": "5PFcCizSVYbwT3JOkbnQjW", "name": "Hunting High and Low" }, + "external_urls": { "spotify": "https://open.spotify.com/track/0eGsygTp906u18L0Oimnem" }, + "duration_ms": 225573, + "type": "track" + } + ], + "limit": 5, + "next": null, + "offset": 0, + "total": 2 + }, + "albums": { + "href": "https://api.spotify.com/v1/search?query=brightside&type=track%2Calbum%2Cplaylist", + "items": [ + { + "id": "1XkGORuUX2QGOEIL4EbJKm", + "name": "Hot Fuss", + "uri": "spotify:album:1XkGORuUX2QGOEIL4EbJKm", + "artists": [{ "id": "0C0XlULifJtAgn6ZNCW2eu", "name": "The Killers" }], + "external_urls": { "spotify": "https://open.spotify.com/album/1XkGORuUX2QGOEIL4EbJKm" }, + "type": "album" + } + ], + "limit": 5, + "next": null, + "offset": 0, + "total": 1 + }, + "playlists": { + "href": "https://api.spotify.com/v1/search?query=brightside&type=track%2Calbum%2Cplaylist", + "items": [ + { + "id": "37i9dQZF1DX", + "name": "This Is The Killers", + "uri": "spotify:playlist:37i9dQZF1DX", + "owner": { "display_name": "Spotify", "id": "spotify" }, + "external_urls": { "spotify": "https://open.spotify.com/playlist/37i9dQZF1DX" }, + "type": "playlist" + }, + null + ], + "limit": 5, + "next": null, + "offset": 0, + "total": 1 + } +} diff --git a/tests/integrations/spotify/test_panel.py b/tests/integrations/spotify/test_panel.py index a608d08..ad7d779 100644 --- a/tests/integrations/spotify/test_panel.py +++ b/tests/integrations/spotify/test_panel.py @@ -14,7 +14,7 @@ PlayerState, Track, ) -from smorg.shell.panel import PanelState +from smorg.shell.panel import Panel, PanelState NOW = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) @@ -25,7 +25,13 @@ def track( album: str = "Hot Fuss", ) -> Track: slug = name.replace(" ", "-").replace("?", "") - return Track(track=name, artists=artists, album=album, url=f"https://open.spotify.com/t/{slug}") + return Track( + track=name, + artists=artists, + album=album, + url=f"https://open.spotify.com/t/{slug}", + uri=f"spotify:track:{slug}", + ) def now_playing( @@ -53,13 +59,22 @@ def state( playing: NowPlaying | None = None, queue: tuple[Track, ...] = (), played: LastPlayed | None = None, + shuffle: bool = False, + repeat: str = "off", ) -> PlayerState: if playing is not None: url = playing.track.url else: url = FALLBACK_URL return PlayerState( - id="player", updated_at=NOW, url=url, now_playing=playing, queue=queue, last_played=played + id="player", + updated_at=NOW, + url=url, + now_playing=playing, + queue=queue, + last_played=played, + shuffle=shuffle, + repeat=repeat, ) @@ -84,9 +99,24 @@ def test_a_playing_track_gets_the_play_icon(): def test_a_paused_track_gets_the_pause_icon(): text = panel_with(state(now_playing(is_playing=False))).ready_text() + banner = text.splitlines()[0] - assert "⏸" in text - assert "▶" not in text + assert banner.startswith("⏸") + assert "▶" not in banner + + +def test_now_playing_leads_and_controls_close_the_body(): + played = last_played() + text = panel_with( + state(now_playing(), queue=(track("Feel Good Inc."),), played=played) + ).ready_text() + lines = text.splitlines() + playing_at = next(index for index, line in enumerate(lines) if "Mr. Brightside" in line) + queue_at = next(index for index, line in enumerate(lines) if "up next" in line) + last_played_at = next(index for index, line in enumerate(lines) if "last played" in line) + controls_at = next(index for index, line in enumerate(lines) if "play/pause" in line) + + assert playing_at < queue_at < last_played_at < controls_at def test_nothing_playing_says_so(): @@ -95,6 +125,24 @@ def test_nothing_playing_says_so(): assert "nothing playing" in text +def test_modes_line_shows_shuffle_and_repeat(): + text = panel_with(state(now_playing(), shuffle=True, repeat="track")).ready_text() + + assert "⇄ on" in text + assert "🔁 track" in text + assert "⏸" in text + + +def test_default_modes_read_as_off(): + text = panel_with(state(now_playing())).ready_text() + + assert "⇄ off" in text + assert "🔁 off" in text + assert "space play/pause" in text + assert ", prev" in text + assert ". next" in text + + # --- The context label --- @@ -191,10 +239,15 @@ class _SpotifyPanelHarness(App[None]): def __init__(self, panel: SpotifyPanel) -> None: super().__init__() self._panel = panel + self.credential_work: list[Panel.CredentialWorkRequested] = [] def compose(self) -> ComposeResult: yield self._panel + def on_panel_credential_work_requested(self, message: Panel.CredentialWorkRequested) -> None: + self.credential_work.append(message) + message.stop() + @pytest.mark.asyncio async def test_pressing_o_opens_the_now_playing_url(monkeypatch): @@ -240,7 +293,7 @@ async def test_pressing_p_opens_the_search_strip_with_the_play_now_placeholder() search = panel.query_one("#player-search", Input) assert search.display is True - assert search.placeholder == "play now — search (not implemented yet)" + assert search.placeholder == "play now — search" assert search.has_focus @@ -255,16 +308,17 @@ async def test_pressing_a_opens_the_search_strip_with_the_add_to_queue_placehold search = panel.query_one("#player-search", Input) assert search.display is True - assert search.placeholder == "add to queue — search (not implemented yet)" + assert search.placeholder == "add to queue — search" @pytest.mark.asyncio -async def test_submitting_the_search_notifies_not_implemented_and_closes_it(monkeypatch): - notified: list[str] = [] - monkeypatch.setattr( - "smorg.integrations.spotify.panel.SpotifyPanel.notify", - lambda self, message, **kwargs: notified.append(message), - ) +async def test_submitting_an_empty_search_warns_and_keeps_the_strip_open(monkeypatch): + notified: list[tuple[str, str | None]] = [] + + def capture(self, message, **kwargs): + notified.append((message, kwargs.get("severity"))) + + monkeypatch.setattr("smorg.integrations.spotify.panel.SpotifyPanel.notify", capture) panel = panel_with(state(now_playing())) async with _SpotifyPanelHarness(panel).run_test() as pilot: panel.focus() @@ -275,9 +329,27 @@ async def test_submitting_the_search_notifies_not_implemented_and_closes_it(monk await pilot.pause() search = panel.query_one("#player-search", Input) - assert search.display is False + assert search.display is True + + assert notified == [("enter a search query", "warning")] + - assert notified == ["not implemented yet — coming with write permissions"] +@pytest.mark.asyncio +async def test_submitting_a_search_posts_credential_work(): + panel = panel_with(state(now_playing())) + async with _SpotifyPanelHarness(panel).run_test() as pilot: + panel.focus() + await pilot.pause() + await pilot.press("p") + await pilot.pause() + await pilot.press(*"brightside") + await pilot.press("enter") + await pilot.pause() + + search = panel.query_one("#player-search", Input) + assert search.display is False + assert len(pilot.app.credential_work) == 1 + assert pilot.app.credential_work[0].refresh_on_success is False @pytest.mark.asyncio @@ -299,6 +371,55 @@ async def test_escape_closes_the_search_strip_and_returns_focus_to_the_panel(): assert panel.has_focus +@pytest.mark.asyncio +async def test_pressing_s_posts_shuffle_toggle(): + panel = panel_with(state(now_playing(), shuffle=False)) + async with _SpotifyPanelHarness(panel).run_test() as pilot: + panel.focus() + await pilot.pause() + await pilot.press("s") + await pilot.pause() + + assert len(pilot.app.credential_work) == 1 + assert pilot.app.credential_work[0].refresh_on_success is True + + +@pytest.mark.asyncio +async def test_pressing_e_posts_repeat_cycle(): + panel = panel_with(state(now_playing(), repeat="off")) + async with _SpotifyPanelHarness(panel).run_test() as pilot: + panel.focus() + await pilot.pause() + await pilot.press("e") + await pilot.pause() + + assert len(pilot.app.credential_work) == 1 + + +@pytest.mark.asyncio +async def test_pressing_space_posts_playback_toggle(): + panel = panel_with(state(now_playing(is_playing=True))) + async with _SpotifyPanelHarness(panel).run_test() as pilot: + panel.focus() + await pilot.pause() + await pilot.press("space") + await pilot.pause() + + assert len(pilot.app.credential_work) == 1 + + +@pytest.mark.asyncio +async def test_pressing_dot_posts_skip_next(): + panel = panel_with(state(now_playing())) + async with _SpotifyPanelHarness(panel).run_test() as pilot: + panel.focus() + await pilot.pause() + await pilot.press(".") + await pilot.pause() + + assert len(pilot.app.credential_work) == 1 + + @pytest.mark.asyncio async def test_escape_is_a_no_op_when_the_search_strip_is_not_showing(): """Escape must not leak into a global dismiss when there is nothing local to dismiss — diff --git a/tests/integrations/spotify/test_pickers.py b/tests/integrations/spotify/test_pickers.py new file mode 100644 index 0000000..e605e5c --- /dev/null +++ b/tests/integrations/spotify/test_pickers.py @@ -0,0 +1,60 @@ +"""Tests for the Spotify search results picker.""" + +from smorg.integrations.spotify.pickers import search_picker +from smorg.integrations.spotify.source import Album, Playlist, SearchResults, Track + + +def track(name: str = "Mr. Brightside") -> Track: + return Track( + track=name, + artists=("The Killers",), + album="Hot Fuss", + url=f"https://open.spotify.com/track/{name}", + uri=f"spotify:track:{name}", + ) + + +def album(name: str = "Hot Fuss") -> Album: + return Album( + name=name, + artists=("The Killers",), + url=f"https://open.spotify.com/album/{name}", + uri=f"spotify:album:{name}", + ) + + +def playlist(name: str = "This Is The Killers") -> Playlist: + return Playlist( + name=name, + owner="Spotify", + url=f"https://open.spotify.com/playlist/{name}", + uri=f"spotify:playlist:{name}", + ) + + +def test_play_picker_lists_a_flat_mix_without_section_headings(): + results = SearchResults(items=(track(), album(), playlist(), track("Take On Me"))) + + picker = search_picker(results, for_queue=False) + text = "\n".join(picker.content_lines()) + + assert picker._title == "play now" + assert "songs" not in text + assert "albums" not in text + assert "playlists" not in text + assert "Mr. Brightside · The Killers · song" in text + assert "Hot Fuss · The Killers · album" in text + assert "This Is The Killers · Spotify · playlist" in text + assert "Take On Me · The Killers · song" in text + + +def test_queue_picker_keeps_songs_only(): + results = SearchResults(items=(track(), album(), playlist())) + + picker = search_picker(results, for_queue=True) + text = "\n".join(picker.content_lines()) + + assert picker._title == "add to queue" + assert "Mr. Brightside · The Killers · song" in text + assert "Hot Fuss" not in text + assert "This Is The Killers" not in text diff --git a/tests/integrations/spotify/test_source.py b/tests/integrations/spotify/test_source.py index 2a48f18..e62b2c8 100644 --- a/tests/integrations/spotify/test_source.py +++ b/tests/integrations/spotify/test_source.py @@ -13,14 +13,38 @@ from smorg.auth.store import Credentials from smorg.core.contract import AccessNotAllowed, AuthExpired, Malformed, Unavailable -from smorg.integrations.spotify.source import fetch +from smorg.integrations.spotify.source import ( + Album, + Playlist, + Track, + fetch, + next_repeat_mode, + pause_playback, + play_context, + play_query, + play_track, + queue_query, + queue_track, + resume_playback, + search, + search_tracks, + set_repeat, + set_shuffle, + skip_next, + skip_previous, +) FIXTURES = Path(__file__).parent / "fixtures" PLAYER = json.loads((FIXTURES / "spotify_player.json").read_text()) QUEUE = json.loads((FIXTURES / "spotify_queue.json").read_text()) RECENTLY_PLAYED = json.loads((FIXTURES / "spotify_recently_played.json").read_text()) +SEARCH = json.loads((FIXTURES / "spotify_search.json").read_text()) EMPTY_QUEUE = {"queue": []} EMPTY_RECENTLY_PLAYED = {"items": []} +EMPTY_SEARCH = {"tracks": {"items": []}, "albums": {"items": []}, "playlists": {"items": []}} +TRACK_URI = "spotify:track:3n3Ppam7vgaVa1iaRUc9Lp" +ALBUM_URI = "spotify:album:1XkGORuUX2QGOEIL4EbJKm" +PLAYLIST_URI = "spotify:playlist:37i9dQZF1DX" CREDENTIALS = Credentials( access_token="spotify-secret-token", @@ -41,6 +65,15 @@ def __init__(self) -> None: self._queue: tuple[int, object] = (200, EMPTY_QUEUE) self._recently_played: tuple[int, object] = (200, EMPTY_RECENTLY_PLAYED) self._playlists: dict[str, tuple[int, object]] = {} + self._search: tuple[int, object] = (200, EMPTY_SEARCH) + self._play: tuple[int, object | None] = (204, None) + self._queue_write: tuple[int, object | None] = (204, None) + self._shuffle: tuple[int, object | None] = (204, None) + self._repeat: tuple[int, object | None] = (204, None) + self._pause: tuple[int, object | None] = (204, None) + self._resume: tuple[int, object | None] = (204, None) + self._next: tuple[int, object | None] = (204, None) + self._previous: tuple[int, object | None] = (204, None) def playing(self, payload: dict, status: int = 200) -> None: self._player = (status, payload) @@ -60,6 +93,21 @@ def played(self, payload: dict, status: int = 200) -> None: def playlist(self, playlist_id: str, payload: dict, status: int = 200) -> None: self._playlists[playlist_id] = (status, payload) + def search_hits(self, payload: dict, status: int = 200) -> None: + self._search = (status, payload) + + def play_result(self, status: int = 204, payload: object | None = None) -> None: + self._play = (status, payload) + + def queue_write_result(self, status: int = 204, payload: object | None = None) -> None: + self._queue_write = (status, payload) + + def shuffle_result(self, status: int = 204, payload: object | None = None) -> None: + self._shuffle = (status, payload) + + def repeat_result(self, status: int = 204, payload: object | None = None) -> None: + self._repeat = (status, payload) + def handler(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) path = request.url.path @@ -69,11 +117,52 @@ def handler(self, request: httpx.Request) -> httpx.Response: return httpx.Response(status) return httpx.Response(status, json=payload) if path == "/v1/me/player/queue": + if request.method == "POST": + status, payload = self._queue_write + if payload is None: + return httpx.Response(status) + return httpx.Response(status, json=payload) status, payload = self._queue return httpx.Response(status, json=payload) + if path == "/v1/me/player/play": + if request.method == "PUT" and not request.content: + status, payload = self._resume + else: + status, payload = self._play + if payload is None: + return httpx.Response(status) + return httpx.Response(status, json=payload) + if path == "/v1/me/player/pause": + status, payload = self._pause + if payload is None: + return httpx.Response(status) + return httpx.Response(status, json=payload) + if path == "/v1/me/player/next": + status, payload = self._next + if payload is None: + return httpx.Response(status) + return httpx.Response(status, json=payload) + if path == "/v1/me/player/previous": + status, payload = self._previous + if payload is None: + return httpx.Response(status) + return httpx.Response(status, json=payload) + if path == "/v1/me/player/shuffle": + status, payload = self._shuffle + if payload is None: + return httpx.Response(status) + return httpx.Response(status, json=payload) + if path == "/v1/me/player/repeat": + status, payload = self._repeat + if payload is None: + return httpx.Response(status) + return httpx.Response(status, json=payload) if path == "/v1/me/player/recently-played": status, payload = self._recently_played return httpx.Response(status, json=payload) + if path == "/v1/search": + status, payload = self._search + return httpx.Response(status, json=payload) if path.startswith("/v1/playlists/"): playlist_id = path.rsplit("/", 1)[-1] if playlist_id not in self._playlists: @@ -139,9 +228,19 @@ def test_now_playing_carries_the_track_and_playback_state(server): assert now_playing.track.artists == ("The Killers",) assert now_playing.track.album == "Hot Fuss" assert now_playing.track.url == "https://open.spotify.com/track/3n3Ppam7vgaVa1iaRUc9Lp" + assert now_playing.track.uri == TRACK_URI assert now_playing.is_playing is True +def test_fetch_carries_shuffle_and_repeat(server): + server.playing(PLAYER | {"shuffle_state": True, "repeat_state": "context"}) + + state = fetch_with(server) + + assert state.shuffle is True + assert state.repeat == "context" + + def test_the_state_id_and_url_reflect_the_now_playing_track(server): server.playing(PLAYER) @@ -357,3 +456,174 @@ def test_a_failure_never_repeats_the_token(server): fetch_with(server) assert "spotify-secret-token" not in str(raised.value) + + +# --- Search, play, queue --- + + +def test_search_returns_the_first_hit(server): + server.search_hits(SEARCH) + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + tracks = search_tracks(CREDENTIALS, http, "brightside") + + assert len(tracks) == 2 + assert tracks[0].track == "Mr. Brightside" + assert tracks[0].uri == TRACK_URI + assert server.requests[0].url.path == "/v1/search" + assert server.requests[0].url.params["type"] == "track" + assert server.requests[0].url.params["limit"] == "5" + + +def test_search_returns_songs_and_playlists(server): + server.search_hits(SEARCH) + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + results = search(CREDENTIALS, http, "brightside") + + assert len(results.items) == 4 + first = results.items[0] + second = results.items[1] + third = results.items[2] + assert isinstance(first, Track) + assert first.track == "Mr. Brightside" + assert isinstance(second, Album) + assert second.name == "Hot Fuss" + assert second.uri == ALBUM_URI + assert isinstance(third, Playlist) + assert third.name == "This Is The Killers" + assert third.uri == PLAYLIST_URI + assert server.requests[0].url.params["type"] == "track,album,playlist" + + +def test_search_skips_null_playlist_hits(server): + server.search_hits(SEARCH) + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + results = search(CREDENTIALS, http, "brightside") + + assert all(getattr(item, "uri", None) for item in results.items) + + +def test_an_empty_search_query_is_malformed(server): + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + with pytest.raises(Malformed): + search_tracks(CREDENTIALS, http, " ") + + +def test_play_track_puts_the_uri(server): + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + play_track(CREDENTIALS, http, TRACK_URI) + + assert server.requests[0].method == "PUT" + assert server.requests[0].url.path == "/v1/me/player/play" + assert server.requests[0].content == b'{"uris":["spotify:track:3n3Ppam7vgaVa1iaRUc9Lp"]}' + + +def test_play_context_puts_the_playlist_uri(server): + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + play_context(CREDENTIALS, http, PLAYLIST_URI) + + assert server.requests[0].method == "PUT" + assert server.requests[0].url.path == "/v1/me/player/play" + assert server.requests[0].content == b'{"context_uri":"spotify:playlist:37i9dQZF1DX"}' + + +def test_queue_track_posts_the_uri(server): + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + queue_track(CREDENTIALS, http, TRACK_URI) + + assert server.requests[0].method == "POST" + assert server.requests[0].url.path == "/v1/me/player/queue" + assert server.requests[0].url.params["uri"] == TRACK_URI + + +def test_play_with_no_active_device_is_unavailable(server): + server.play_result(404, {"error": {"status": 404, "message": "No active device"}}) + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + with pytest.raises(Unavailable, match="no active Spotify device"): + play_track(CREDENTIALS, http, TRACK_URI) + + +def test_play_query_searches_then_plays(server): + server.search_hits(SEARCH) + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + track = play_query(CREDENTIALS, http, "brightside") + + assert track.uri == TRACK_URI + paths = [request.url.path for request in server.requests] + assert paths == ["/v1/search", "/v1/me/player/play"] + + +def test_queue_query_searches_then_queues(server): + server.search_hits(SEARCH) + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + track = queue_query(CREDENTIALS, http, "brightside") + + assert track.uri == TRACK_URI + paths = [request.url.path for request in server.requests] + assert paths == ["/v1/search", "/v1/me/player/queue"] + + +def test_play_query_with_no_hits_is_unavailable(server): + server.search_hits(EMPTY_SEARCH) + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + with pytest.raises(Unavailable, match="no tracks matched"): + play_query(CREDENTIALS, http, "zzzz") + + +def test_set_shuffle_puts_the_state(server): + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + assert set_shuffle(CREDENTIALS, http, True) is True + assert server.requests[0].method == "PUT" + assert server.requests[0].url.path == "/v1/me/player/shuffle" + assert server.requests[0].url.params["state"] == "true" + + +def test_set_repeat_puts_the_mode(server): + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + assert set_repeat(CREDENTIALS, http, "track") == "track" + assert server.requests[0].method == "PUT" + assert server.requests[0].url.path == "/v1/me/player/repeat" + assert server.requests[0].url.params["state"] == "track" + + +def test_next_repeat_mode_cycles_off_context_track(): + assert next_repeat_mode("off") == "context" + assert next_repeat_mode("context") == "track" + assert next_repeat_mode("track") == "off" + assert next_repeat_mode("weird") == "off" + + +def test_pause_and_resume_hit_the_player_endpoints(server): + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + pause_playback(CREDENTIALS, http) + resume_playback(CREDENTIALS, http) + + assert [(request.method, request.url.path) for request in server.requests] == [ + ("PUT", "/v1/me/player/pause"), + ("PUT", "/v1/me/player/play"), + ] + + +def test_skip_next_and_previous_post(server): + http = httpx.Client(transport=httpx.MockTransport(server.handler)) + + skip_next(CREDENTIALS, http) + skip_previous(CREDENTIALS, http) + + assert [(request.method, request.url.path) for request in server.requests] == [ + ("POST", "/v1/me/player/next"), + ("POST", "/v1/me/player/previous"), + ] From 3ffb0e99edcc860c7e30598550dc868a02aab4aa Mon Sep 17 00:00:00 2001 From: Sushant Gangwani <81021277+sushant2812@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:59:14 -0400 Subject: [PATCH 2/4] Delete .DS_Store --- .DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5665e43f9c29d705ee54abfe472de808ca178d50..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jx&8L5QS$*0xQv^Oz9p8DHW{~EhmUXjY1FSRhOnw2Z10E1U?DK{*X{avuiQctpk-_0ibe>Rzq9Qw9q6DXm%~8@W9xn zLTxJh6@zU${K@08YcaLy#QJ2c<4^v$ys)3BIcYd?HjO$61c5ezo^^+k|8Matbzbt@ zQ=%6Hg1|o`!2NnwPw`QnTYr3>J{ From c02bf2ba38103e09fa6cd489b3db3c171be17112 Mon Sep 17 00:00:00 2001 From: Sushant Gangwani <81021277+sushant2812@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:00:36 -0400 Subject: [PATCH 3/4] Updated roadmap --- docs/ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7957cd1..9bbab00 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,7 +24,7 @@ ### Spotify -- (none currently) +- Allow users to add songs to playlists ## General capabilities @@ -36,4 +36,4 @@ ## Patches -- Show update progress \ No newline at end of file +- Show update progress From cfc03b5026b7a9c2452e2151062442e176634927 Mon Sep 17 00:00:00 2001 From: Sushant Gangwani Date: Tue, 15 Sep 2026 00:08:59 -0400 Subject: [PATCH 4/4] added album ascii art --- pyproject.toml | 1 + src/smorg/integrations/spotify/albumart.py | 63 ++++++++ src/smorg/integrations/spotify/panel.py | 162 +++++++++++++++++-- src/smorg/integrations/spotify/source.py | 58 +++++++ test.py | 171 ++++++++++++++++++++ tests/integrations/spotify/test_albumart.py | 48 ++++++ tests/integrations/spotify/test_panel.py | 67 +++++++- tests/integrations/spotify/test_source.py | 69 ++++++++ uv.lock | 73 +++++++++ 9 files changed, 700 insertions(+), 12 deletions(-) create mode 100644 src/smorg/integrations/spotify/albumart.py create mode 100644 test.py create mode 100644 tests/integrations/spotify/test_albumart.py diff --git a/pyproject.toml b/pyproject.toml index f6f4be7..a281627 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ dependencies = [ "httpx>=0.28", "keyring>=25.0", + "pillow>=12.3.0", "pygithub>=2.5", "requests>=2.32", "textual>=1.0", diff --git a/src/smorg/integrations/spotify/albumart.py b/src/smorg/integrations/spotify/albumart.py new file mode 100644 index 0000000..c70e92d --- /dev/null +++ b/src/smorg/integrations/spotify/albumart.py @@ -0,0 +1,63 @@ +"""Turn album artwork bytes into colored terminal ASCII.""" + +from __future__ import annotations + +from io import BytesIO + +from PIL import Image, ImageEnhance +from rich.color import Color +from rich.style import Style +from rich.text import Text + +# Characters go from dark -> bright. +CHARS = ' .`^",:;Il!i~+_-?][}{1)(|\\/~tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$' +# Terminal cells are roughly twice as tall as they are wide. +CELL_ASPECT = 0.5 + + +def image_to_ascii( + data: bytes, + width: int, + *, + contrast: float = 1.0, + brightness: float = 1.0, +) -> Text | None: + """Colored ASCII art of `data`, `width` cells wide, or None if the bytes are not an image.""" + if width < 1 or not data: + return None + try: + image = Image.open(BytesIO(data)).convert("RGB") + except (OSError, ValueError): + return None + if contrast != 1.0: + image = ImageEnhance.Contrast(image).enhance(contrast) + if brightness != 1.0: + image = ImageEnhance.Brightness(image).enhance(brightness) + original_width, original_height = image.size + if original_width < 1 or original_height < 1: + return None + height = max(1, int((original_height / original_width) * width * CELL_ASPECT)) + image = image.resize((width, height), Image.Resampling.LANCZOS) + pixels = image.load() + if pixels is None: + return None + max_char_index = len(CHARS) - 1 + lines: list[Text] = [] + for y in range(height): + line = Text() + for x in range(width): + pixel = pixels[x, y] + if not isinstance(pixel, tuple): + continue + red = int(pixel[0]) + green = int(pixel[1]) + blue = int(pixel[2]) + luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue + char_index = int((luminance / 255) * max_char_index) + char = CHARS[char_index] + color = Color.from_rgb(red, green, blue) + line.append(char, style=Style(color=color)) + lines.append(line) + body = Text("\n").join(lines) + body.no_wrap = True + return body diff --git a/src/smorg/integrations/spotify/panel.py b/src/smorg/integrations/spotify/panel.py index 393be21..7f1fcbf 100644 --- a/src/smorg/integrations/spotify/panel.py +++ b/src/smorg/integrations/spotify/panel.py @@ -10,8 +10,9 @@ from textual import events from textual.app import ComposeResult from textual.binding import Binding -from textual.widgets import Input +from textual.widgets import Input, Static +from smorg.integrations.spotify.albumart import image_to_ascii from smorg.integrations.spotify.pickers import search_picker from smorg.integrations.spotify.source import ( FALLBACK_URL, @@ -35,7 +36,7 @@ skip_previous, ) from smorg.shell.format import age -from smorg.shell.panel import Panel +from smorg.shell.panel import Panel, ViewBody _DIM = "dim" @@ -53,6 +54,11 @@ _PLAY_NOW_PLACEHOLDER = "play now — search" _ADD_TO_QUEUE_PLACEHOLDER = "add to queue — search" +# Square cover at this cell width is about 32 rows, which sits beside the queue. +_ART_WIDTH = 64 +# Hide the cover when the tab is too narrow for the queue and the art together. +_ART_MIN_PANEL_WIDTH = 100 + class _SearchAction(StrEnum): PLAY = "play" @@ -166,9 +172,63 @@ def _format_last_played(last_played: LastPlayed | None) -> list[Text]: return lines +def _space_rows(rows: list[Text], slack: int) -> list[Text]: + """`rows` with a blank row between as many adjacent pairs as `slack` affords, spread evenly.""" + boundaries = len(rows) - 1 + if boundaries < 1 or slack < 1: + return list(rows) + filled = min(slack, boundaries) + spaced: list[Text] = [] + for index, row in enumerate(rows[:-1]): + spaced.append(row) + # Cumulative split, so a partial slack spreads down the list instead of piling on top. + if filled * (index + 1) // boundaries > filled * index // boundaries: + spaced.append(Text()) + spaced.append(rows[-1]) + return spaced + + +def _spread(sections: list[list[Text]], target_height: int) -> list[Text]: + """Sections stacked a blank row apart, the leftover rows shared out to fill `target_height`.""" + gaps = len(sections) - 1 + lines: list[Text] = [] + if gaps < 1: + for section in sections: + lines.extend(section) + return lines + rows = sum(len(section) for section in sections) + slack = max(target_height - rows - gaps, 0) + for index, section in enumerate(sections[:-1]): + lines.extend(section) + # Cumulative split, so a slack that doesn't divide evenly still lands every row. + extra = slack * (index + 1) // gaps - slack * index // gaps + lines.extend(Text() for _ in range(1 + extra)) + lines.extend(sections[-1]) + return lines + + +def _body_lines( + banner: list[Text], + queue: list[Text], + last_played: list[Text], + controls: list[Text], + target_height: int, +) -> list[Text]: + """The four sections stacked to fill `target_height`, the queue taking the room it can use + before the section gaps widen. + """ + rows = len(banner) + len(queue) + len(last_played) + len(controls) + # Three one-row gaps hold the four sections apart before any of the slack is handed out. + slack = max(target_height - rows - 3, 0) + # "up next" hugs its list, so only the rows under it are spaced. + spaced_queue = queue[:1] + _space_rows(queue[1:], slack) + return _spread([banner, spaced_queue, last_played, controls], target_height) + + class SpotifyPanel(Panel): - DEFAULT_CSS = """ - SpotifyPanel > #player-search { dock: bottom; } + DEFAULT_CSS = f""" + SpotifyPanel > #player-search {{ dock: bottom; }} + SpotifyPanel > #album-art {{ dock: right; width: {_ART_WIDTH}; height: auto; padding-left: 2; }} """ BINDINGS = [ @@ -187,13 +247,95 @@ def __init__(self) -> None: super().__init__() self._search_action: _SearchAction | None = None self._work_pending = False + self._art_render: tuple[bytes, int, Text] | None = None def compose(self) -> ComposeResult: yield from super().compose() + album_art = ViewBody(self._render_album_art, id="album-art") + album_art.display = False + yield album_art search_input = Input(id="player-search") search_input.display = False yield search_input + def on_mount(self) -> None: + self._sync_album_art() + + def on_resize(self, event: events.Resize) -> None: + self._sync_album_art() + + def _refresh_body(self, repaint: bool, layout: bool) -> None: + super()._refresh_body(repaint, layout) + self._sync_album_art() + + def _album_art_bytes(self) -> bytes | None: + state = self._state() + if state is None or state.now_playing is None: + return None + return state.now_playing.album_art + + def _art_width(self) -> int: + if not self.is_mounted: + return _ART_WIDTH + width = self.query_one("#album-art", Static).size.width + if width > 0: + return width + return _ART_WIDTH + + def _can_show_art(self) -> bool: + if self._album_art_bytes() is None: + return False + if not self.is_mounted: + return True + panel_width = self.size.width + if panel_width > 0 and panel_width < _ART_MIN_PANEL_WIDTH: + return False + return True + + def _sync_album_art(self) -> None: + if not self.is_mounted: + return + art = self.query_one("#album-art", Static) + art.display = self._can_show_art() + art.refresh() + + def _art_text(self) -> Text | None: + """The cover as ASCII, cached so the body and the art column share the one render.""" + data = self._album_art_bytes() + if data is None or not self._can_show_art(): + return None + width = self._art_width() + cached = self._art_render + if cached is not None: + cached_data, cached_width, cached_text = cached + if cached_width == width and cached_data == data: + return cached_text + rendered = image_to_ascii(data, width) + if rendered is None: + return None + self._art_render = (data, width, rendered) + return rendered + + def _render_album_art(self) -> Text: + rendered = self._art_text() + if rendered is None: + return Text() + return rendered + + def _fill_height(self) -> int: + """Rows the body spreads over so it ends level with the cover, 0 when there is no cover.""" + art = self._art_text() + if art is None: + return 0 + rows = art.plain.count("\n") + 1 + if not self.is_mounted: + return rows + # Never spread past the body's own region: the controls would fall off the bottom. + available = self.query_one("#body", Static).size.height + if available < 1: + return rows + return min(rows, available) + def _state(self) -> PlayerState | None: if len(self.items) != 1: return None @@ -211,17 +353,15 @@ def render_ready(self) -> Text: if state is None: lines.append(Text("nothing playing", style=_DIM)) else: - lines.extend(_format_banner(state.now_playing)) - lines.append(Text()) - lines.extend(_format_queue(state.queue)) - lines.append(Text()) - lines.extend(_format_last_played(state.last_played)) - lines.append(Text()) if state.now_playing is None: playing = None else: playing = state.now_playing.is_playing - lines.extend(_format_controls(state.shuffle, state.repeat, playing)) + banner = _format_banner(state.now_playing) + queue = _format_queue(state.queue) + played = _format_last_played(state.last_played) + controls = _format_controls(state.shuffle, state.repeat, playing) + lines = _body_lines(banner, queue, played, controls, self._fill_height()) body = Text("\n").join(lines) # One row per track: a wrapped row spills into the next row's place and breaks the layout. body.no_wrap = True diff --git a/src/smorg/integrations/spotify/source.py b/src/smorg/integrations/spotify/source.py index 7cdb0c4..86c1578 100644 --- a/src/smorg/integrations/spotify/source.py +++ b/src/smorg/integrations/spotify/source.py @@ -38,6 +38,9 @@ LAST_PLAYED_LIMIT = 1 SEARCH_LIMIT = 5 REPEAT_MODES = ("off", "context", "track") +# Spotify serves 64 / 300 / 640; 300 is enough pixels for terminal ASCII without a huge download. +PREFERRED_ART_SIZE = 300 +ART_MAX_BYTES = 2 * 1024 * 1024 # Where "o" opens when nothing is loaded on the player at all. FALLBACK_URL = "https://open.spotify.com" @@ -90,6 +93,8 @@ class NowPlaying: context_kind: str # None when there is nothing to name (autoplay, or a name that could not be resolved). context_name: str | None + # Cover JPEG/PNG bytes, or None when Spotify omitted images or the download failed. + album_art: bytes | None = None @dataclass(frozen=True) @@ -492,11 +497,13 @@ def _now_playing_of( raise Malformed(f"'item' was {type(item).__name__}, expected an object") track = _track_of(item) context_kind, context_name = _context_of(payload.get("context"), track, credentials, http) + album_art = _album_art_of(item, http) return NowPlaying( track=track, is_playing=_is_playing_of(payload), context_kind=context_kind, context_name=context_name, + album_art=album_art, ) @@ -646,3 +653,54 @@ def _album_name(track: dict[str, Any]) -> str: if not isinstance(album, dict): raise Malformed(f"'album' was {type(album).__name__}, expected an object") return required_string(album, "name") + + +def _album_art_of(track: dict[str, Any], http: httpx.Client) -> bytes | None: + """Cover bytes for the now-playing track, or None on any failure — missing art must not + break the tab. + """ + url = _album_art_url(track) + if url is None: + return None + return _download_album_art(http, url) + + +def _album_art_url(track: dict[str, Any]) -> str | None: + album = track.get("album") + if not isinstance(album, dict): + return None + images = album.get("images") + if not isinstance(images, list): + return None + candidates: list[tuple[int, str]] = [] + for image in images: + if not isinstance(image, dict): + continue + url = image.get("url") + if not isinstance(url, str): + continue + if urlsplit(url).scheme != "https": + continue + width = image.get("width") + if isinstance(width, int): + size = width + else: + size = 0 + candidates.append((size, url)) + if not candidates: + return None + ranked = sorted(candidates, key=lambda item: (abs(item[0] - PREFERRED_ART_SIZE), -item[0])) + return ranked[0][1] + + +def _download_album_art(http: httpx.Client, url: str) -> bytes | None: + try: + response = http.get(url) + except httpx.HTTPError: + return None + if response.status_code != 200: + return None + content = response.content + if not content or len(content) > ART_MAX_BYTES: + return None + return content diff --git a/test.py b/test.py new file mode 100644 index 0000000..623a946 --- /dev/null +++ b/test.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +import argparse +import sys +from PIL import Image, ImageEnhance + +# Characters go from dark -> bright. +DEFAULT_CHARS = " .`^\",:;Il!i~+_-?][}{1)(|\\/~tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$" + + +def image_to_ascii(image_path, width=100, contrast=1.0, brightness=1.0): + try: + img = Image.open(image_path).convert("RGB") + except Exception as e: + print(f"Error opening image: {e}", file=sys.stderr) + sys.exit(1) + + # Improve contrast/brightness if requested. + if contrast != 1.0: + img = ImageEnhance.Contrast(img).enhance(contrast) + + if brightness != 1.0: + img = ImageEnhance.Brightness(img).enhance(brightness) + + original_width, original_height = img.size + + # Terminal characters are roughly twice as tall as they are wide. + # Compensate for that so the image doesn't look stretched vertically. + height = max(1, int((original_height / original_width) * width * 0.5)) + + img = img.resize((width, height), Image.Resampling.LANCZOS) + + chars = DEFAULT_CHARS + max_char_index = len(chars) - 1 + + output = [] + + for y in range(height): + line = [] + + for x in range(width): + r, g, b = img.getpixel((x, y)) + + # Perceived brightness (better than a simple RGB average). + luminance = ( + 0.2126 * r + + 0.7152 * g + + 0.0722 * b + ) + + char_index = int((luminance / 255) * max_char_index) + char = chars[char_index] + + # True-color ANSI escape sequence. + line.append( + f"\033[38;2;{r};{g};{b}m{char}" + ) + + line.append("\033[0m") + output.append("".join(line)) + + return "\n".join(output) + + +def main(): + parser = argparse.ArgumentParser( + description="Convert album artwork into colored terminal ASCII art." + ) + + parser.add_argument( + "image", + help="Path to album artwork (jpg, png, webp, etc.)" + ) + + parser.add_argument( + "-w", + "--width", + type=int, + default=100, + help="Output width in characters (default: 100)" + ) + + parser.add_argument( + "-c", + "--contrast", + type=float, + default=1.0, + help="Contrast multiplier (default: 1.0)" + ) + + parser.add_argument( + "-b", + "--brightness", + type=float, + default=1.0, + help="Brightness multiplier (default: 1.0)" + ) + + parser.add_argument( + "--no-color", + action="store_true", + help="Disable ANSI colors" + ) + + args = parser.parse_args() + + if args.width < 10: + parser.error("Width must be at least 10.") + + try: + img = Image.open(args.image).convert("RGB") + except Exception as e: + print(f"Error opening image: {e}", file=sys.stderr) + sys.exit(1) + + # Adjust image. + if args.contrast != 1.0: + img = ImageEnhance.Contrast(img).enhance(args.contrast) + + if args.brightness != 1.0: + img = ImageEnhance.Brightness(img).enhance(args.brightness) + + original_width, original_height = img.size + + height = max( + 1, + int((original_height / original_width) * args.width * 0.5) + ) + + img = img.resize( + (args.width, height), + Image.Resampling.LANCZOS + ) + + chars = DEFAULT_CHARS + max_char_index = len(chars) - 1 + + for y in range(height): + line = [] + + for x in range(args.width): + r, g, b = img.getpixel((x, y)) + + # Perceived brightness. + luminance = ( + 0.2126 * r + + 0.7152 * g + + 0.0722 * b + ) + + char_index = int( + (luminance / 255) * max_char_index + ) + + char = chars[char_index] + + if args.no_color: + line.append(char) + else: + line.append( + f"\033[38;2;{r};{g};{b}m{char}" + ) + + if not args.no_color: + line.append("\033[0m") + + print("".join(line)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/integrations/spotify/test_albumart.py b/tests/integrations/spotify/test_albumart.py new file mode 100644 index 0000000..ab52757 --- /dev/null +++ b/tests/integrations/spotify/test_albumart.py @@ -0,0 +1,48 @@ +"""Tests for converting album artwork bytes into colored ASCII.""" + +from io import BytesIO + +from PIL import Image +from rich.style import Style +from rich.text import Text + +from smorg.integrations.spotify.albumart import CELL_ASPECT, image_to_ascii + + +def png_bytes(color: tuple[int, int, int] = (255, 0, 0), size: int = 8) -> bytes: + image = Image.new("RGB", (size, size), color) + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def test_a_square_image_is_half_as_tall_as_it_is_wide(): + width = 20 + rendered = image_to_ascii(png_bytes(), width) + + assert rendered is not None + lines = rendered.plain.splitlines() + assert len(lines) == int(width * CELL_ASPECT) + assert all(len(line) == width for line in lines) + + +def test_pixels_carry_their_color(): + rendered = image_to_ascii(png_bytes((0, 128, 255)), width=4) + + assert rendered is not None + assert isinstance(rendered, Text) + spans = rendered._spans + assert spans + first_style = spans[0].style + assert isinstance(first_style, Style) + color = first_style.color + assert color is not None + triplet = color.triplet + assert triplet is not None + assert triplet.blue > triplet.red + + +def test_unreadable_bytes_yield_nothing(): + assert image_to_ascii(b"not an image", width=12) is None + assert image_to_ascii(b"", width=12) is None + assert image_to_ascii(png_bytes(), width=0) is None diff --git a/tests/integrations/spotify/test_panel.py b/tests/integrations/spotify/test_panel.py index ad7d779..d548ee5 100644 --- a/tests/integrations/spotify/test_panel.py +++ b/tests/integrations/spotify/test_panel.py @@ -1,10 +1,11 @@ """Tests for the Spotify panel: one player-state snapshot, no cursor, no seen state.""" +from dataclasses import replace from datetime import UTC, datetime, timedelta import pytest from textual.app import App, ComposeResult -from textual.widgets import Input +from textual.widgets import Input, Static from smorg.integrations.spotify.panel import SpotifyPanel from smorg.integrations.spotify.source import ( @@ -16,6 +17,8 @@ ) from smorg.shell.panel import Panel, PanelState +from .test_albumart import png_bytes + NOW = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) @@ -207,6 +210,68 @@ def test_plain_output_is_derived_from_the_styled_render(): assert panel.ready_text() == panel.render_ready().plain.strip() +# --- Album art --- + + +@pytest.mark.asyncio +async def test_album_art_docks_on_the_right_when_the_cover_is_present(): + cover = replace(now_playing(), album_art=png_bytes()) + panel = panel_with(state(cover)) + async with _SpotifyPanelHarness(panel).run_test(size=(140, 40)) as pilot: + await pilot.pause() + art = panel.query_one("#album-art", Static) + assert art.display is True + rendered = panel._render_album_art() + assert rendered.plain.strip() != "" + + +@pytest.mark.asyncio +async def test_the_body_spreads_so_it_ends_level_with_the_cover(): + cover = replace(now_playing(), album_art=png_bytes()) + panel = panel_with(state(cover, queue=(track("Feel Good Inc."),), played=last_played())) + async with _SpotifyPanelHarness(panel).run_test(size=(140, 40)) as pilot: + await pilot.pause() + art_rows = len(panel._render_album_art().plain.splitlines()) + body_rows = len(panel.render_ready().plain.splitlines()) + + assert art_rows > 1 + assert body_rows == art_rows + + +@pytest.mark.asyncio +async def test_the_queue_takes_the_slack_before_the_section_gaps_do(): + cover = replace(now_playing(), album_art=png_bytes()) + queued = tuple(track(f"Song {index}") for index in range(1, 21)) + panel = panel_with(state(cover, queue=queued, played=last_played())) + async with _SpotifyPanelHarness(panel).run_test(size=(140, 40)) as pilot: + await pilot.pause() + body = panel.render_ready().plain + + lines = body.splitlines() + first_song = next(index for index, line in enumerate(lines) if "Song 1 ·" in line) + assert lines[first_song + 1].strip() == "" + assert "Song 2 ·" in lines[first_song + 2] + # Two blank rows in a row would mean a section gap widened before the songs were spaced. + assert "\n\n\n" not in body + + +def test_the_body_stays_compact_when_there_is_no_cover(): + panel = panel_with(state(now_playing(), queue=(track("Feel Good Inc."),), played=last_played())) + + lines = panel.render_ready().plain.splitlines() + + assert lines.count("") == 3 + + +@pytest.mark.asyncio +async def test_album_art_stays_hidden_when_there_is_no_cover(): + panel = panel_with(state(now_playing())) + async with _SpotifyPanelHarness(panel).run_test(size=(140, 40)) as pilot: + await pilot.pause() + art = panel.query_one("#album-art", Static) + assert art.display is False + + # --- Last played --- diff --git a/tests/integrations/spotify/test_source.py b/tests/integrations/spotify/test_source.py index e62b2c8..0695ef6 100644 --- a/tests/integrations/spotify/test_source.py +++ b/tests/integrations/spotify/test_source.py @@ -45,6 +45,15 @@ TRACK_URI = "spotify:track:3n3Ppam7vgaVa1iaRUc9Lp" ALBUM_URI = "spotify:album:1XkGORuUX2QGOEIL4EbJKm" PLAYLIST_URI = "spotify:playlist:37i9dQZF1DX" +ART_URL = "https://i.scdn.co/image/hot-fuss" +ART_BYTES = b"cover-bytes" + + +def player_with_images(*images: dict) -> dict: + item = PLAYER["item"] + album = item["album"] | {"images": list(images)} + return PLAYER | {"item": item | {"album": album}} + CREDENTIALS = Credentials( access_token="spotify-secret-token", @@ -74,6 +83,7 @@ def __init__(self) -> None: self._resume: tuple[int, object | None] = (204, None) self._next: tuple[int, object | None] = (204, None) self._previous: tuple[int, object | None] = (204, None) + self._images: dict[str, tuple[int, bytes]] = {} def playing(self, payload: dict, status: int = 200) -> None: self._player = (status, payload) @@ -96,6 +106,9 @@ def playlist(self, playlist_id: str, payload: dict, status: int = 200) -> None: def search_hits(self, payload: dict, status: int = 200) -> None: self._search = (status, payload) + def image(self, url: str, body: bytes, status: int = 200) -> None: + self._images[url] = (status, body) + def play_result(self, status: int = 204, payload: object | None = None) -> None: self._play = (status, payload) @@ -110,6 +123,10 @@ def repeat_result(self, status: int = 204, payload: object | None = None) -> Non def handler(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) + image = self._images.get(str(request.url)) + if image is not None: + status, body = image + return httpx.Response(status, content=body) path = request.url.path if path == "/v1/me/player": status, payload = self._player @@ -282,6 +299,58 @@ def test_an_episode_is_treated_as_nothing_playing(server): assert fetch_with(server).now_playing is None +# --- Album art --- + + +def test_now_playing_downloads_the_cover_closest_to_300px(server): + payload = player_with_images( + {"url": "https://i.scdn.co/image/large", "width": 640, "height": 640}, + {"url": ART_URL, "width": 300, "height": 300}, + {"url": "https://i.scdn.co/image/tiny", "width": 64, "height": 64}, + ) + server.playing(payload) + server.image(ART_URL, ART_BYTES) + + now_playing = fetch_with(server).now_playing + + assert now_playing is not None + assert now_playing.album_art == ART_BYTES + art_urls = [str(request.url) for request in server.requests if request.url.host == "i.scdn.co"] + assert art_urls == [ART_URL] + + +def test_a_missing_cover_leaves_album_art_empty_with_no_extra_call(server): + server.playing(PLAYER) + + now_playing = fetch_with(server).now_playing + + assert now_playing is not None + assert now_playing.album_art is None + assert all(request.url.host != "i.scdn.co" for request in server.requests) + + +def test_a_failing_cover_download_degrades_to_no_art_not_an_error(server): + payload = player_with_images({"url": ART_URL, "width": 300, "height": 300}) + server.playing(payload) + server.image(ART_URL, b"", status=404) + + now_playing = fetch_with(server).now_playing + + assert now_playing is not None + assert now_playing.album_art is None + + +def test_a_non_https_cover_url_is_ignored(server): + payload = player_with_images({"url": "http://i.scdn.co/image/hot-fuss", "width": 300}) + server.playing(payload) + + now_playing = fetch_with(server).now_playing + + assert now_playing is not None + assert now_playing.album_art is None + assert all(request.url.host != "i.scdn.co" for request in server.requests) + + # --- Context: what's driving playback --- diff --git a/uv.lock b/uv.lock index c4ebdb5..23603b2 100644 --- a/uv.lock +++ b/uv.lock @@ -490,6 +490,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.11.2" @@ -715,6 +786,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "keyring" }, + { name = "pillow" }, { name = "pygithub" }, { name = "requests" }, { name = "textual" }, @@ -733,6 +805,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.28" }, { name = "keyring", specifier = ">=25.0" }, + { name = "pillow", specifier = ">=12.3.0" }, { name = "pygithub", specifier = ">=2.5" }, { name = "requests", specifier = ">=2.32" }, { name = "textual", specifier = ">=1.0" },