Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ classifiers = [
dependencies = [
"httpx>=0.28",
"keyring>=25.0",
"pillow>=11.3.0",
"pygithub>=2.5",
"requests>=2.32",
"textual>=1.0",
Expand Down
60 changes: 60 additions & 0 deletions src/smorg/integrations/spotify/albumart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Turn album artwork bytes into colored terminal ASCII."""

from __future__ import annotations

from io import BytesIO

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) -> Text | None:
"""Colored ASCII art of `data`, `width` cells wide, or None if the bytes are not an image.

Pillow is imported lazily so a missing or broken install just turns the cover off rather than
crashing the tab (and lets the dependency be optional).
"""
if width < 1 or not data:
return None
try:
from PIL import Image
except ImportError:
return None
try:
image = Image.open(BytesIO(data)).convert("RGB")
except (OSError, ValueError):
return None
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
87 changes: 83 additions & 4 deletions src/smorg/integrations/spotify/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,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.source import (
FALLBACK_URL,
LastPlayed,
Expand All @@ -18,7 +19,7 @@
Track,
)
from smorg.shell.format import age
from smorg.shell.panel import Panel
from smorg.shell.panel import Panel, ViewBody

_DIM = "dim"

Expand All @@ -34,6 +35,11 @@
_PLAY_NOW_PLACEHOLDER = "play now — search (not implemented yet)"
_ADD_TO_QUEUE_PLACEHOLDER = "add to queue — search (not implemented yet)"

# 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


def _format_artists(artists: tuple[str, ...]) -> str:
"""("Tame Impala", "Kevin Parker") -> "Tame Impala, Kevin Parker" """
Expand Down Expand Up @@ -103,8 +109,9 @@ def _format_last_played(last_played: LastPlayed | None) -> list[Text]:


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 = [
Expand All @@ -114,12 +121,84 @@ class SpotifyPanel(Panel):
]
can_focus = True

def __init__(self) -> None:
super().__init__()
# (bytes, width, rendered) so the last cover is reused until the track or width changes.
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(id="player-search")
search.display = False
yield search

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 a re-render reuses the last paint of the same art."""
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 _state(self) -> PlayerState | None:
if len(self.items) != 1:
return None
Expand Down
58 changes: 58 additions & 0 deletions src/smorg/integrations/spotify/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
# Where "o" opens when nothing is loaded on the player at all.
FALLBACK_URL = "https://open.spotify.com"

PREFERRED_ART_SIZE = 300
ART_MAX_BYTES = 2 * 1024 * 1024


@dataclass(frozen=True)
class Track:
Expand All @@ -50,6 +53,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)
Expand Down Expand Up @@ -140,11 +145,13 @@ def _fetch_now_playing(credentials: Credentials, http: httpx.Client) -> NowPlayi
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,
)


Expand Down Expand Up @@ -265,3 +272,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
48 changes: 48 additions & 0 deletions tests/integrations/spotify/test_albumart.py
Original file line number Diff line number Diff line change
@@ -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
Loading