4 Commits
9 changed files with 38 additions and 292 deletions

No files matched your search

+26 -1
View File
@@ -38,7 +38,7 @@ jobs:
# No `submodules:` on the checkout above, so the tap is empty here and the
# sdist below differs from a local `uv build` with the submodule populated.
# The published asset is therefore the authority: tools/bump_packaging.py
# The published asset is therefore the authority: bump_packaging.py
# hashes the download, never a rebuild.
- name: Build the sdist and wheel
run: |
@@ -72,3 +72,28 @@ jobs:
tag: ${{ forgejo.ref_name }}
title: ${{ forgejo.ref_name }}
release-notes-file: dist/notes.md
# Every packaging channel (AUR, the Homebrew tap and the flake) downloads
# its source from tildeforge, so the release is published there as well.
# That repository is not a mirror of this one: nothing reaches it unless
# it is pushed, hence the tag push before the upload. Pushing a tag that
# is already there and identical is a no-op, so this stays re-runnable.
- name: Push the tag to tildeforge
env:
TILDEFORGE_TOKEN: ${{ secrets.TILDEFORGE_TOKEN }}
run: |
git push \
"https://beefcarver:${TILDEFORGE_TOKEN}@tildeforge.dev/beefcarver/beefy-player.git" \
"refs/tags/${FORGEJO_REF_NAME}"
- name: Publish the release to tildeforge
uses: actions/forgejo-release@v2
with:
direction: upload
url: https://tildeforge.dev
repo: beefcarver/beefy-player
token: ${{ secrets.TILDEFORGE_TOKEN }}
override: true
tag: ${{ forgejo.ref_name }}
title: ${{ forgejo.ref_name }}
release-notes-file: dist/notes.md
+1 -2
View File
@@ -35,7 +35,6 @@ git submodule update --init # contrib/homebrew, the Homebrew tap
uv run pytest # full test suite
uvx ruff check . # lint (ruff is not a project dependency)
uv run beefy-player --list # station ids for the current catalogue
uv run python tools/ansi_art.py ... # regenerate a provider's header art
```
`uvx ruff check .` is the enforced lint and must pass. `ruff format` is **not**
@@ -61,7 +60,7 @@ repo-wide reformat as a side effect of an unrelated change.
`.forgejo/workflows/release.yml` fires on a `v*` tag: it asserts that the tag,
the `pyproject.toml` version and `beefy_player.__version__` all agree, then
publishes release notes. It does **not** touch packaging, so run
`tools/bump_packaging.py <version>` after publishing: it rewrites
`uv run python bump_packaging.py <version>` after publishing: it rewrites
`contrib/aur/PKGBUILD` and regenerates its `.SRCINFO` from it, rewrites the tap
formula in `contrib/homebrew`, and with `--commit` commits the tap first and
then this repository. Both channels build the sdist attached to the release,
+2 -5
View File
@@ -330,15 +330,12 @@ Each catalogue is a self-contained provider module under
`beefy_player/providers/`; `base.py` documents the interface. Every catalogue's
header is a wordmark from the shared half-block font, and the header paints it
with the active palette gradient (`hero_gradient`, on by default) so all
thirteen follow the terminal's colours. `tools/ansi_art.py` converts a PNG logo
into half-block ANSI art that carries colours of its own; a provider using that
art must set `hero_gradient = False`, because the header would otherwise
repaint it with the gradient.
thirteen follow the terminal's colours.
`uvx ruff check .` is the enforced lint. The tree is not `ruff format`-clean (54
files would change), so do not reformat it wholesale.
`tools/bump_packaging.py <version>` keeps the AUR package and the Homebrew tap
`uv run python bump_packaging.py <version>` keeps the AUR package and the Homebrew tap
formula in step with a release — it rewrites `PKGBUILD`, regenerates
`.SRCINFO` from it (no makepkg needed), rewrites the formula, and `--commit`s
both. `--check` reports drift without writing.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Beefy Player — a flashy terminal player for internet radio."""
__version__ = "1.2.4"
__version__ = "1.2.5"
@@ -15,10 +15,10 @@ generated on the fly. The sdist's root directory is hatchling's PEP 503 name
Usage:
python tools/bump_packaging.py 1.2.3 # download the release asset
python tools/bump_packaging.py 1.2.3 --asset dist/beefy_player-1.2.3.tar.gz
python tools/bump_packaging.py 1.2.3 --commit # also commit both repos
python tools/bump_packaging.py 1.2.3 --check # verify, write nothing
python bump_packaging.py 1.2.3 # download the release asset
python bump_packaging.py 1.2.3 --asset dist/beefy_player-1.2.3.tar.gz
python bump_packaging.py 1.2.3 --commit # also commit both repos
python bump_packaging.py 1.2.3 --check # verify, write nothing
`.SRCINFO` is never edited directly; it is regenerated by evaluating
`PKGBUILD` in bash and serialising it in makepkg's field order. That keeps it
@@ -106,7 +106,7 @@ done
#: Replaces the `_commit`-pinned git-archive source with the release asset.
ASSET_SOURCE = (
"# The sdist attached to the v$pkgver release. tools/bump_packaging.py keeps\n"
"# The sdist attached to the v$pkgver release. bump_packaging.py keeps\n"
"# this and sha256sums in step with the tag; build/check/package use\n"
"# $_srcname because the sdist unpacks to hatchling's PEP 503 name.\n"
'_srcname="beefy_player-$pkgver"\n'
+1 -1
View File
@@ -34,7 +34,7 @@
in
py.buildPythonApplication {
pname = "beefy-player";
version = "1.2.4";
version = "1.2.5";
pyproject = true;
src = self;
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "beefy-player"
version = "1.2.4"
version = "1.2.5"
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
readme = "README.md"
license = "WTFPL"
-275
View File
@@ -1,275 +0,0 @@
#!/usr/bin/env python3
"""Turn a logo into half-block ANSI art for the header hero.
The header hero is a handful of text rows, so an image has to survive a brutal
downsample: two pixels per cell (upper/lower half block), one ANSI colour per
half. Colours are quantised to the stock 16-colour ANSI palette on purpose —
the rest of the UI never emits RGB, and a hero built from palette names still
reads as the station's art in any terminal theme.
Shipped catalogues no longer use image art: each one renders a wordmark from
beefy_player/providers/wordmark.py, which the header repaints with the active
palette gradient (RadioProvider.hero_gradient, on by default). Art from this
tool carries colours of its own, so a provider that uses it must set
hero_gradient = False — otherwise the header repaints the art with the gradient
and the palette names below are wasted.
Usage:
python tools/ansi_art.py logo.png --cols 22 --rows 7
python tools/ansi_art.py --url https://example.org/logo.png \\
--cols 22 --rows 7 --module beefy_player/providers/<slug>/hero.py
``--preview out.png`` draws what the cells will look like, which is much easier
to judge than a wall of ▀ and ▄.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from PIL import Image, ImageDraw
#: The stock xterm colours the names below resolve to in a terminal.
ANSI16: dict[str, tuple[int, int, int]] = {
"black": (0, 0, 0),
"red": (170, 0, 0),
"green": (0, 170, 0),
"yellow": (170, 85, 0),
"blue": (0, 0, 170),
"magenta": (170, 0, 170),
"cyan": (0, 170, 170),
"white": (170, 170, 170),
"bright_black": (85, 85, 85),
"bright_red": (255, 85, 85),
"bright_green": (85, 255, 85),
"bright_yellow": (255, 255, 85),
"bright_blue": (85, 85, 255),
"bright_magenta": (255, 85, 255),
"bright_cyan": (85, 255, 255),
"bright_white": (255, 255, 255),
}
#: Below this alpha a half-cell counts as empty (terminal background).
ALPHA_CUTOFF = 110
#: Ink weaker than this is treated as fringe when cropping the image.
CROP_CUTOFF = 96
def nearest_colour(rgb: tuple[int, int, int]) -> str:
best, best_distance = "white", float("inf")
for name, reference in ANSI16.items():
distance = sum((a - b) ** 2 for a, b in zip(rgb, reference, strict=True))
if distance < best_distance:
best, best_distance = name, distance
return best
def key_white(image: Image.Image, tolerance: int) -> Image.Image:
"""Make near-white pixels transparent.
A logo exported on a white background (or one whose anti-aliasing bleeds
white) otherwise survives the downsample as a glaring box.
"""
image = image.copy()
pixels = image.load()
width, height = image.size
threshold = 255 - max(0, tolerance)
for y in range(height):
for x in range(width):
red, green, blue, alpha = pixels[x, y]
if alpha and min(red, green, blue) >= threshold:
pixels[x, y] = (red, green, blue, 0)
return image
def resize_premultiplied(image: Image.Image, size: tuple[int, int]) -> Image.Image:
"""Resize in premultiplied alpha, so transparent pixels cannot bleed in.
Plain ``Image.resize`` averages the RGB of fully transparent pixels (often
white) with the surrounding ink, which is how a clean logo turns into a
grey haze at 20x14.
"""
import numpy as np
data = np.asarray(image.convert("RGBA"), dtype=np.float32)
alpha = data[..., 3:4] / 255.0
premultiplied = np.concatenate([data[..., :3] * alpha, data[..., 3:4]], axis=-1)
small = Image.fromarray(premultiplied.astype(np.uint8), "RGBA").resize(
size, Image.LANCZOS
)
out = np.asarray(small, dtype=np.float32)
out_alpha = out[..., 3:4] / 255.0
rgb = out[..., :3] / np.maximum(out_alpha, 1e-6)
return Image.fromarray(
np.concatenate([np.clip(rgb, 0, 255), out[..., 3:4]], axis=-1).astype(np.uint8),
"RGBA",
)
def load_image(
path: Path | None, url: str | None, key_white_background: bool = False
) -> Image.Image:
if path is None and url:
import httpx
response = httpx.get(url, follow_redirects=True, timeout=30.0)
response.raise_for_status()
from io import BytesIO
image = Image.open(BytesIO(response.content))
elif path is not None:
image = Image.open(path)
else: # pragma: no cover - argparse enforces one of the two
raise SystemExit("pass an image path or --url")
image = image.convert("RGBA")
if key_white_background:
image = key_white(image, 24)
# Trim the transparent margin: the art should fill the box it is given.
opaque = image.getchannel("A").point(lambda value: 255 if value > CROP_CUTOFF else 0)
box = opaque.getbbox()
return image.crop(box) if box else image
def art_rows(image: Image.Image, cols: int, rows: int) -> list[list[tuple[str, str, str]]]:
"""(glyph, foreground, background) per cell, two source pixels per cell."""
small = resize_premultiplied(image, (cols, rows * 2))
pixels = small.load()
grid: list[list[tuple[str, str, str]]] = []
for row in range(rows):
cells: list[tuple[str, str, str]] = []
for column in range(cols):
top = pixels[column, row * 2]
bottom = pixels[column, row * 2 + 1]
top_ink = top[3] >= ALPHA_CUTOFF
bottom_ink = bottom[3] >= ALPHA_CUTOFF
if not top_ink and not bottom_ink:
cells.append((" ", "", ""))
elif top_ink and bottom_ink:
cells.append(("▀", nearest_colour(top[:3]), nearest_colour(bottom[:3])))
elif top_ink:
cells.append(("▀", nearest_colour(top[:3]), ""))
else:
cells.append(("▄", nearest_colour(bottom[:3]), ""))
grid.append(cells)
return grid
def markup(grid: list[list[tuple[str, str, str]]]) -> list[str]:
"""One rich-markup string per row, run-length encoded by style."""
lines: list[str] = []
for cells in grid:
line: list[str] = []
index = 0
while index < len(cells):
glyph, foreground, background = cells[index]
if not background and glyph == " ":
line.append(" ")
index += 1
continue
run = [glyph]
index += 1
while index < len(cells) and cells[index] == (glyph, foreground, background):
run.append(cells[index][0])
index += 1
style = " ".join(
part for part in (foreground, f"on {background}" if background else "") if part
)
body = "".join(run)
line.append(f"[{style}]{body}[/]" if style else body)
lines.append("".join(line).rstrip())
return lines
def preview(grid: list[list[tuple[str, str, str]]], path: Path, cell: tuple[int, int]) -> None:
width, height = cell
image = Image.new("RGB", (len(grid[0]) * width, len(grid) * height), (24, 24, 24))
draw = ImageDraw.Draw(image)
for row, cells in enumerate(grid):
for column, (glyph, foreground, background) in enumerate(cells):
x, y = column * width, row * height
if background:
draw.rectangle([x, y, x + width - 1, y + height - 1], fill=ANSI16[background])
if glyph == " ":
continue
colour = ANSI16.get(foreground, (140, 140, 140))
if glyph == "▀":
box = [x, y, x + width - 1, y + height // 2 - 1]
else:
box = [x, y + height // 2, x + width - 1, y + height - 1]
draw.rectangle(box, fill=colour)
image.resize((image.width * 3, image.height * 3), Image.NEAREST).save(path)
MODULE_TEMPLATE = '''"""Generated header art — do not edit by hand.
{provenance}
Regenerate with::
{command}
Each row is rich markup; ``▀`` is a half block whose foreground is the top
pixel and whose background is the pixel below it. Colours are stock ANSI
palette names, so the art keeps the terminal's own palette.
The header paints every hero with the active palette gradient unless the
provider opts out, so the class using this art must set
``hero_gradient = False``.
"""
from __future__ import annotations
HERO: tuple[str, ...] = (
{rows}
)
'''
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("image", nargs="?", type=Path, help="local image file")
parser.add_argument("--url", help="fetch the image from this URL instead")
parser.add_argument("--cols", type=int, default=22, help="terminal columns (default 22)")
parser.add_argument("--rows", type=int, default=7, help="terminal rows (default 7)")
parser.add_argument("--module", type=Path, help="write a Python module with HERO")
parser.add_argument("--preview", type=Path, help="write a PNG preview of the cells")
parser.add_argument("--provenance", default="", help="source note for the module header")
parser.add_argument(
"--key-white",
action="store_true",
help="treat near-white pixels as transparent (logos on white)",
)
args = parser.parse_args(argv)
image = load_image(args.image, args.url, args.key_white)
grid = art_rows(image, args.cols, args.rows)
rows = markup(grid)
if args.preview:
preview(grid, args.preview, (10, 20))
if args.module:
source = args.provenance or args.url or str(args.image)
command = "uv run python tools/ansi_art.py " + " ".join(sys.argv[1:])
body = "\n".join(f" {row!r}," for row in rows)
args.module.write_text(
MODULE_TEMPLATE.format(
provenance=f"Source: {source}",
command=command,
rows=body,
),
encoding="utf-8",
)
print(f"wrote {args.module} ({len(rows)} rows x {args.cols} cols)")
else:
print("\n".join(rows))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Generated
+1 -1
View File
@@ -32,7 +32,7 @@ wheels = [
[[package]]
name = "beefy-player"
version = "1.2.4"
version = "1.2.5"
source = { editable = "." }
dependencies = [
{ name = "dbus-next", marker = "sys_platform == 'linux'" },