refactor!: rename somafm-tui to beefy-player
The app is now a general internet-radio player rather than a SomaFM client, so the package, console script, MPRIS identity, mpv namespaces, macOS tap names, XDG directories and docs all move to beefy-player. Environment variables are now BEEFY_* (the old SOMAFM_* spellings are still honoured) and an existing ~/.config/somafm-tui/config.json is migrated on first run, so favourites and volume survive the rename. The visible interface is untouched: same layout, same logo, same keys.
This commit is contained in:
1 parent
4bed90b489
commit
e30ca09627
44 files changed
+329
-192
No files matched your search
@@ -0,0 +1,251 @@
|
||||
"""Terminal artwork support.
|
||||
|
||||
The TUI draws a procedural ANSI mosaic when the terminal cannot display real
|
||||
images. On terminals with a graphics protocol we fetch the station's album
|
||||
art (or channel logo) and render it directly, with the mosaic behind it:
|
||||
|
||||
* Kitty -> Terminal Graphics Protocol (direct image placement)
|
||||
* iTerm2 / WezTerm -> native iTerm2 inline images (OSC 1337)
|
||||
|
||||
The protocols are implemented directly; there is no terminal capability probe
|
||||
and no extra terminal input, so nothing leaks into the shell at exit.
|
||||
Set ``BEEFY_IMAGES=0`` to force the ANSI fallback, or ``BEEFY_IMAGES=tgp`` /
|
||||
``BEEFY_IMAGES=iterm2`` to force a protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Mapping
|
||||
from io import BytesIO
|
||||
|
||||
import httpx
|
||||
|
||||
from . import config
|
||||
|
||||
_OFF_VALUES = {"0", "off", "no", "none", "false"}
|
||||
_PROTOCOL_VALUES = {"tgp", "iterm2"}
|
||||
|
||||
|
||||
def _env_flag(env: Mapping[str, str], name: str) -> bool:
|
||||
"""True when an environment variable is set to a meaningful value.
|
||||
|
||||
Multiplexers are inconsistent here (``TMUX`` is a path, ``STY`` a
|
||||
session name) and some wrappers export ``ZELLIJ=0``/``TMUX=0`` when they
|
||||
are *not* running, so a plain truthiness check would wrongly downgrade a
|
||||
perfectly good iTerm2/Kitty to the ANSI mosaic.
|
||||
"""
|
||||
value = (env.get(name) or "").strip()
|
||||
return bool(value) and value.lower() not in _OFF_VALUES
|
||||
|
||||
|
||||
def detect_protocol(environ: Mapping[str, str] | None = None) -> str | None:
|
||||
"""Return ``"tgp"``, ``"iterm2"`` or ``None`` for the current terminal."""
|
||||
env = os.environ if environ is None else environ
|
||||
if "NO_COLOR" in env:
|
||||
return None
|
||||
override = (
|
||||
env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES") or ""
|
||||
).strip().lower()
|
||||
if override in _OFF_VALUES:
|
||||
return None
|
||||
if override in _PROTOCOL_VALUES:
|
||||
return override
|
||||
|
||||
term = (env.get("TERM") or "").lower()
|
||||
program = (env.get("TERM_PROGRAM") or "").lower()
|
||||
|
||||
if (
|
||||
env.get("KITTY_WINDOW_ID")
|
||||
or env.get("KITTY_PID")
|
||||
or "kitty" in term
|
||||
or "kitty" in program
|
||||
):
|
||||
# Kitty can receive TGP through tmux passthrough; we wrap the
|
||||
# sequences for tmux. The mosaic remains behind the image if the
|
||||
# multiplexer blocks graphics.
|
||||
return "tgp"
|
||||
|
||||
# Kitty inside tmux may not inherit TERM=xterm-kitty/KITTY_*. Ask tmux
|
||||
# what the outer terminal is; TGP is wrapped for tmux passthrough.
|
||||
if _env_flag(env, "TMUX") and shutil.which("tmux"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "display-message", "-p", "#{client_termname}"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=1.0,
|
||||
check=False,
|
||||
)
|
||||
if "kitty" in result.stdout.lower():
|
||||
return "tgp"
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# iTerm2/WezTerm inside tmux would need their own passthrough wrapper, so
|
||||
# default to the mosaic there.
|
||||
if (
|
||||
_env_flag(env, "TMUX")
|
||||
or _env_flag(env, "ZELLIJ")
|
||||
or _env_flag(env, "STY")
|
||||
or term.startswith("screen")
|
||||
or term.startswith("tmux")
|
||||
or program in {"tmux", "screen"}
|
||||
):
|
||||
return None
|
||||
|
||||
if (
|
||||
program in {"wezterm", "iterm.app"}
|
||||
or env.get("WEZTERM_EXECUTABLE")
|
||||
or env.get("ITERM_SESSION_ID")
|
||||
):
|
||||
# WezTerm and iTerm2 both implement the iTerm2 inline-image protocol
|
||||
# natively; WezTerm's TGP placeholders are less reliable inside Textual.
|
||||
return "iterm2"
|
||||
return None
|
||||
|
||||
|
||||
PROTOCOL = detect_protocol()
|
||||
|
||||
|
||||
def supported() -> bool:
|
||||
return PROTOCOL in ("tgp", "iterm2")
|
||||
|
||||
|
||||
def cell_size() -> tuple[int, int]:
|
||||
"""Terminal cell size in pixels (used to crop artwork to its cell box)."""
|
||||
import sys
|
||||
|
||||
width = os.environ.get("TEXTUAL_CELL_WIDTH", "")
|
||||
height = os.environ.get("TEXTUAL_CELL_HEIGHT", "")
|
||||
if width.isdigit() and height.isdigit() and int(width) > 0 and int(height) > 0:
|
||||
return int(width), int(height)
|
||||
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
import fcntl
|
||||
import struct
|
||||
import termios
|
||||
|
||||
stdout = sys.__stdout__
|
||||
if stdout is not None and stdout.isatty():
|
||||
packed = fcntl.ioctl(
|
||||
stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8
|
||||
)
|
||||
_, _, pixel_width, pixel_height = struct.unpack("HHHH", packed)
|
||||
terminal = os.get_terminal_size(stdout.fileno())
|
||||
if (
|
||||
terminal.columns > 0
|
||||
and terminal.lines > 0
|
||||
and pixel_width > 0
|
||||
and pixel_height > 0
|
||||
):
|
||||
return (
|
||||
max(1, pixel_width // terminal.columns),
|
||||
max(1, pixel_height // terminal.lines),
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return 10, 20
|
||||
|
||||
|
||||
def _cache_path(url: str):
|
||||
digest = hashlib.sha1(url.encode("utf-8")).hexdigest()
|
||||
return config.ARTWORK_CACHE_DIR / digest
|
||||
|
||||
|
||||
def load_cached(url: str) -> bytes | None:
|
||||
try:
|
||||
data = _cache_path(url).read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
return data or None
|
||||
|
||||
|
||||
def save_cached(url: str, data: bytes) -> None:
|
||||
try:
|
||||
path = _cache_path(url)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
tmp.write_bytes(data)
|
||||
os.replace(tmp, path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def diagnostics() -> dict[str, object]:
|
||||
"""Return a small dict describing image support for --diagnose-images."""
|
||||
import sys
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
def pkg_version(name: str) -> str:
|
||||
try:
|
||||
return version(name)
|
||||
except PackageNotFoundError:
|
||||
return "not installed"
|
||||
|
||||
env = os.environ
|
||||
return {
|
||||
"tty": bool(sys.__stdout__ is not None and sys.__stdout__.isatty()),
|
||||
"TERM": env.get("TERM", ""),
|
||||
"TERM_PROGRAM": env.get("TERM_PROGRAM", ""),
|
||||
"KITTY_WINDOW_ID": env.get("KITTY_WINDOW_ID", ""),
|
||||
"WEZTERM_EXECUTABLE": env.get("WEZTERM_EXECUTABLE", ""),
|
||||
"ITERM_SESSION_ID": env.get("ITERM_SESSION_ID", ""),
|
||||
"TMUX": env.get("TMUX", ""),
|
||||
"ZELLIJ": env.get("ZELLIJ", ""),
|
||||
"BEEFY_IMAGES": env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES", ""),
|
||||
"NO_COLOR": "set" if "NO_COLOR" in env else "",
|
||||
"protocol": PROTOCOL,
|
||||
"supported": supported(),
|
||||
"cell_size": cell_size(),
|
||||
"pillow": pkg_version("pillow"),
|
||||
"cache": str(config.ARTWORK_CACHE_DIR),
|
||||
}
|
||||
|
||||
|
||||
async def fetch(client: httpx.AsyncClient, url: str) -> bytes | None:
|
||||
"""Download and validate an image, caching the bytes on disk."""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
try:
|
||||
response = await client.get(url, headers={"Accept": "image/*,*/*"})
|
||||
response.raise_for_status()
|
||||
data = response.content
|
||||
if not data:
|
||||
return None
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
image.verify()
|
||||
save_cached(url, data)
|
||||
return data
|
||||
except (httpx.HTTPError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def fit_image(data: bytes, width: int, height: int):
|
||||
"""Decode and center-crop an image to exactly ``width`` x ``height`` cells."""
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
image = source.convert("RGBA")
|
||||
cell_width, cell_height = cell_size()
|
||||
target = (
|
||||
max(1, width * cell_width),
|
||||
max(1, height * cell_height),
|
||||
)
|
||||
return ImageOps.fit(
|
||||
image,
|
||||
target,
|
||||
method=Image.Resampling.LANCZOS,
|
||||
centering=(0.5, 0.5),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
Reference in new issue
Block a user