The epd4in0e panel's palette has no gray at all (just black/white/ yellow/red/blue/green -- see epd4in0e.py's putpalette), so any gray RGB fill gets Floyd-Steinberg dithered into a black/white checkerboard across the whole glyph body. At the small font sizes most of these screens use (Navidrome's artist/album/genre/source lines, Departures' direction/platform columns, Weather's description/forecast labels, Clock's date line, the placeholder messages), that reads as blurry, half-rendered text -- same root cause already fixed in Journey. Switched every gray fill/outline to solid black across navidrome.py, weather.py, departures.py, clock.py, and picture.py. Left the intentionally-faint row-divider hairlines (228,228,228) alone -- those are meant to almost disappear, not readable text. Removed departures.py's now-unused MUTED constant.
354 lines
15 KiB
Python
354 lines
15 KiB
Python
"""Navidrome "now playing" screen -- ported from the InkyPi plugin.
|
|
|
|
Same Subsonic API logic as before (salted-MD5 auth, filter on state=="playing"
|
|
rather than the unreliable minutesAgo, live positionMs for elapsed time), now
|
|
rendering directly with Pillow instead of HTML/CSS + headless Chromium.
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import random
|
|
import time
|
|
from io import BytesIO
|
|
|
|
import requests
|
|
from PIL import Image, ImageDraw, ImageEnhance
|
|
|
|
from screens.base import Screen
|
|
from screens.render_utils import load_font, text_width, truncate_to_width, wrap_to_lines
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SUBSONIC_API_VERSION = "1.16.1"
|
|
CLIENT_NAME = "Paperwall"
|
|
HTTP_TIMEOUT = 10
|
|
|
|
REMEMBERED_FIELDS = (
|
|
"id", "title", "artist", "album", "year", "genre",
|
|
"coverArt", "duration", "starred", "track", "username", "playerName",
|
|
)
|
|
|
|
|
|
class NavidromeScreen(Screen):
|
|
id = "navidrome"
|
|
display_name = "Navidrome Now Playing"
|
|
poll_interval = 20
|
|
|
|
def default_settings(self) -> dict:
|
|
return {
|
|
"url": "",
|
|
"username": "",
|
|
"password": "",
|
|
"filter_user": "",
|
|
"show_progress": False,
|
|
"show_genre": True,
|
|
"show_source": True,
|
|
"boost_cover": True,
|
|
"grayscale_cover": False,
|
|
"_last_track": None,
|
|
"_last_track_at": None,
|
|
}
|
|
|
|
# ---------------------------------------------------------------- check
|
|
|
|
def check(self, settings: dict):
|
|
url = (settings.get("url") or "").strip().rstrip("/")
|
|
username = (settings.get("username") or "").strip()
|
|
password = settings.get("password") or ""
|
|
if not url or not username or not password:
|
|
return None
|
|
|
|
only_user = (settings.get("filter_user") or "").strip()
|
|
try:
|
|
entry = self._fetch_now_playing(url, username, password, only_user)
|
|
except requests.exceptions.RequestException as exc:
|
|
logger.error("Navidrome request failed: %s", exc)
|
|
return None
|
|
|
|
if entry is not None:
|
|
settings["_last_track"] = {k: entry.get(k) for k in REMEMBERED_FIELDS}
|
|
settings["_last_track_at"] = time.time()
|
|
|
|
return entry
|
|
|
|
def _auth_params(self, username: str, password: str) -> dict:
|
|
salt = "%08x" % random.getrandbits(32)
|
|
token = hashlib.md5((password + salt).encode("utf-8")).hexdigest()
|
|
return {"u": username, "t": token, "s": salt, "v": SUBSONIC_API_VERSION, "c": CLIENT_NAME, "f": "json"}
|
|
|
|
def _call(self, url: str, username: str, password: str, method: str, params: dict | None = None) -> dict:
|
|
query = self._auth_params(username, password)
|
|
query.update(params or {})
|
|
response = requests.get(f"{url}/rest/{method}", params=query, timeout=HTTP_TIMEOUT)
|
|
response.raise_for_status()
|
|
payload = response.json().get("subsonic-response", {})
|
|
if payload.get("status") != "ok":
|
|
message = payload.get("error", {}).get("message", "unknown error")
|
|
raise RuntimeError(f"Navidrome API error on {method}: {message}")
|
|
return payload
|
|
|
|
def _fetch_now_playing(self, url: str, username: str, password: str, only_user: str):
|
|
"""Return the currently-playing entry, or None.
|
|
|
|
Navidrome's `minutesAgo` counts from when the session *started*, not
|
|
since the last heartbeat, so a track over 5 minutes long would look
|
|
"stale" under a naive staleness filter while still genuinely playing.
|
|
Filtering on `state == "playing"` instead is correct -- Navidrome
|
|
expires stopped sessions server-side already.
|
|
"""
|
|
payload = self._call(url, username, password, "getNowPlaying")
|
|
entries = payload.get("nowPlaying", {}).get("entry", []) or []
|
|
|
|
if only_user:
|
|
entries = [e for e in entries if e.get("username") == only_user]
|
|
|
|
entries = [e for e in entries if e.get("state") == "playing"]
|
|
if not entries:
|
|
return None
|
|
|
|
return min(entries, key=lambda e: e.get("minutesAgo") or 0)
|
|
|
|
# --------------------------------------------------------------- render
|
|
|
|
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
|
idle = state is None
|
|
entry = state if not idle else settings.get("_last_track")
|
|
|
|
width, height = resolution
|
|
image = Image.new("RGB", (width, height), "white")
|
|
draw = ImageDraw.Draw(image)
|
|
|
|
if entry is None:
|
|
self._render_empty(draw, width, height)
|
|
return image
|
|
|
|
track = self._build_track(entry, settings, idle)
|
|
cover = None
|
|
if entry.get("coverArt"):
|
|
cover = self._fetch_cover(settings, entry["coverArt"])
|
|
|
|
landscape = width > height
|
|
if landscape:
|
|
self._render_landscape(image, draw, track, cover, settings, idle, width, height)
|
|
else:
|
|
self._render_portrait(image, draw, track, cover, settings, idle, width, height)
|
|
|
|
return image
|
|
|
|
def _build_track(self, entry: dict, settings: dict, idle: bool) -> dict:
|
|
duration = int(entry.get("duration") or 0)
|
|
if idle:
|
|
since = time.time() - float(settings.get("_last_track_at") or time.time())
|
|
minutes_ago = (int(since // 60) // 5) * 5
|
|
else:
|
|
minutes_ago = entry.get("minutesAgo") or 0
|
|
|
|
return {
|
|
"title": entry.get("title") or "Unknown title",
|
|
"artist": entry.get("artist") or "Unknown artist",
|
|
"album": entry.get("album") or "",
|
|
"year": entry.get("year"),
|
|
"genre": entry.get("genre"),
|
|
"starred": bool(entry.get("starred")),
|
|
"player": entry.get("playerName") or "",
|
|
"listener": entry.get("username") or "",
|
|
"minutes_ago": minutes_ago,
|
|
"duration": duration,
|
|
"duration_text": _format_time(duration),
|
|
}
|
|
|
|
def _fetch_cover(self, settings: dict, cover_art_id: str):
|
|
url = (settings.get("url") or "").strip().rstrip("/")
|
|
username = (settings.get("username") or "").strip()
|
|
password = settings.get("password") or ""
|
|
query = self._auth_params(username, password)
|
|
query.update({"id": cover_art_id, "size": 600})
|
|
try:
|
|
response = requests.get(f"{url}/rest/getCoverArt", params=query, timeout=HTTP_TIMEOUT)
|
|
response.raise_for_status()
|
|
image = Image.open(BytesIO(response.content)).convert("RGB")
|
|
except (requests.exceptions.RequestException, OSError) as exc:
|
|
logger.warning("Could not load cover art %s: %s", cover_art_id, exc)
|
|
return None
|
|
|
|
if settings.get("boost_cover", True):
|
|
image = ImageEnhance.Contrast(image).enhance(1.35)
|
|
image = ImageEnhance.Brightness(image).enhance(1.05)
|
|
|
|
if settings.get("grayscale_cover", False):
|
|
image = image.convert("L").convert("RGB")
|
|
|
|
return image
|
|
|
|
# ------------------------------------------------------------- layouts
|
|
|
|
def _render_empty(self, draw: ImageDraw.ImageDraw, width: int, height: int):
|
|
font = load_font(size=int(min(width, height) * 0.06))
|
|
msg = "Nichts läuft gerade"
|
|
mw = text_width(draw, msg, font)
|
|
draw.text(((width - mw) / 2, height / 2), msg, font=font, fill="black")
|
|
|
|
def _render_landscape(self, image, draw, track, cover, settings, idle, width, height):
|
|
pad = int(width * 0.025)
|
|
# Cap at a fraction of width too, not just height -- a naive full-height
|
|
# square cover can eat almost the whole canvas on a wide/short panel and
|
|
# leave the text column too narrow to wrap sensibly.
|
|
available_h = height - 2 * pad
|
|
cover_size = min(available_h, int(width * 0.5))
|
|
cover_x = pad
|
|
cover_y = pad + (available_h - cover_size) // 2 # center vertically when width-constrained
|
|
|
|
self._paste_cover(image, cover, cover_x, cover_y, cover_size)
|
|
|
|
meta_x = cover_x + cover_size + int(width * 0.03)
|
|
meta_w = width - meta_x - pad
|
|
self._draw_meta(draw, track, settings, idle, meta_x, pad, meta_w, height - 2 * pad, min(width, height))
|
|
|
|
def _render_portrait(self, image, draw, track, cover, settings, idle, width, height):
|
|
pad = int(width * 0.06)
|
|
cover_size = width - 2 * pad
|
|
cover_x, cover_y = pad, pad
|
|
|
|
self._paste_cover(image, cover, cover_x, cover_y, cover_size)
|
|
|
|
meta_y = cover_y + cover_size + int(height * 0.03)
|
|
meta_h = height - meta_y - pad
|
|
self._draw_meta(
|
|
draw, track, settings, idle, pad, meta_y, width - 2 * pad, meta_h, min(width, height), center=True
|
|
)
|
|
|
|
def _paste_cover(self, image, cover, x, y, size):
|
|
if cover is not None:
|
|
fitted = _cover_fit(cover, size)
|
|
image.paste(fitted, (x, y))
|
|
else:
|
|
draw = ImageDraw.Draw(image)
|
|
draw.rectangle((x, y, x + size, y + size), outline="black", width=2)
|
|
font = load_font(size=int(size * 0.3))
|
|
note = "♪"
|
|
nw = text_width(draw, note, font)
|
|
draw.text((x + (size - nw) / 2, y + size * 0.35), note, font=font, fill="black")
|
|
|
|
def _draw_meta(self, draw, track, settings, idle, x, y, w, h, scale, center=False):
|
|
status_font = load_font(size=int(scale * 0.038), bold=True)
|
|
title_font = load_font(size=int(scale * 0.075), bold=True)
|
|
artist_font = load_font(size=int(scale * 0.05))
|
|
album_font = load_font(size=int(scale * 0.036))
|
|
source_font = load_font(size=int(scale * 0.03))
|
|
|
|
def centered_x(text, font):
|
|
tw = text_width(draw, text, font)
|
|
return x + (w - tw) / 2 if center else x
|
|
|
|
# Reserve space for the bottom-pinned source line up front, so the
|
|
# block above can be vertically centered in what's actually left
|
|
# rather than always starting at the very top -- hugging the top
|
|
# left a large, unbalanced gap above the source line whenever the
|
|
# track had short/sparse metadata.
|
|
show_source = settings.get("show_source", True)
|
|
source_reserved_h = source_font.size * 1.6 if show_source else 0
|
|
content_h = h - source_reserved_h
|
|
|
|
# Build the content block as (height, draw_fn) pairs first so its
|
|
# total height is known before committing to a starting y.
|
|
items: list[tuple[float, object]] = []
|
|
|
|
status = "ZULETZT GESPIELT" if idle else "● NOW PLAYING"
|
|
|
|
def draw_status(yy):
|
|
draw.text((centered_x(status, status_font), yy), status, font=status_font, fill="black")
|
|
|
|
items.append((status_font.size * 1.8, draw_status))
|
|
|
|
title = track["title"] + (" ★" if track["starred"] else "")
|
|
for line in wrap_to_lines(draw, title, title_font, w, max_lines=2):
|
|
def draw_title_line(yy, line=line):
|
|
draw.text((centered_x(line, title_font), yy), line, font=title_font, fill="black")
|
|
items.append((title_font.size * 1.15, draw_title_line))
|
|
if items:
|
|
items[-1] = (items[-1][0] + title_font.size * 0.2, items[-1][1])
|
|
|
|
artist_line = truncate_to_width(draw, track["artist"], artist_font, w)
|
|
|
|
def draw_artist(yy):
|
|
draw.text((centered_x(artist_line, artist_font), yy), artist_line, font=artist_font, fill="black")
|
|
|
|
items.append((artist_font.size * 1.4, draw_artist))
|
|
|
|
if track["album"]:
|
|
album_line = track["album"]
|
|
if track["year"]:
|
|
album_line += f" · {track['year']}"
|
|
album_line = truncate_to_width(draw, album_line, album_font, w)
|
|
|
|
def draw_album(yy, album_line=album_line):
|
|
draw.text((centered_x(album_line, album_font), yy), album_line, font=album_font, fill="black")
|
|
|
|
items.append((album_font.size * 1.4, draw_album))
|
|
|
|
if settings.get("show_genre", True) and track["genre"]:
|
|
genre_line = truncate_to_width(draw, track["genre"].upper(), album_font, w)
|
|
|
|
def draw_genre(yy, genre_line=genre_line):
|
|
draw.text((centered_x(genre_line, album_font), yy), genre_line, font=album_font, fill="black")
|
|
|
|
items.append((album_font.size * 1.4, draw_genre))
|
|
|
|
if settings.get("show_progress", False) and track["duration"]:
|
|
# Purely decorative -- an outline, not a fill. This panel has no
|
|
# partial refresh, so anything tracking *live* playback position
|
|
# would change on every poll and force a full flash every ~20s
|
|
# even mid-song. Shows total length only, never changes while
|
|
# the same track keeps playing.
|
|
bar_h = max(3, int(scale * 0.012))
|
|
bar_gap = status_font.size * 0.3
|
|
|
|
def draw_progress(yy, bar_h=bar_h, bar_gap=bar_gap):
|
|
bar_y = yy + bar_gap
|
|
draw.rectangle((x, bar_y, x + w, bar_y + bar_h), outline="black")
|
|
draw.text((centered_x(track["duration_text"], source_font), bar_y + bar_h + status_font.size * 0.4), track["duration_text"], font=source_font, fill="black")
|
|
|
|
items.append((bar_gap + bar_h + status_font.size * 0.4 + source_font.size * 1.6, draw_progress))
|
|
elif track["duration"]:
|
|
duration_badge = track["duration_text"]
|
|
|
|
def draw_duration(yy, duration_badge=duration_badge):
|
|
draw.text((centered_x(duration_badge, source_font), yy), duration_badge, font=source_font, fill="black")
|
|
|
|
items.append((source_font.size * 1.6, draw_duration))
|
|
|
|
total_h = sum(item_h for item_h, _ in items)
|
|
cursor = y + max(0.0, (content_h - total_h) / 2)
|
|
for item_h, draw_fn in items:
|
|
draw_fn(cursor)
|
|
cursor += item_h
|
|
|
|
if show_source:
|
|
if idle:
|
|
source_line = f"vor {track['minutes_ago']} min"
|
|
else:
|
|
parts = [p for p in (track["player"], track["listener"]) if p]
|
|
source_line = " · ".join(parts)
|
|
if source_line:
|
|
source_line = truncate_to_width(draw, source_line, source_font, w)
|
|
# Prefer pinning to the bottom of the available area, but never
|
|
# overlap whatever the block above drew -- long titles/metadata
|
|
# can still push past where a fixed bottom position would land.
|
|
bottom_y = max(cursor + status_font.size * 0.4, y + h - source_font.size * 1.3)
|
|
draw.text((centered_x(source_line, source_font), bottom_y), source_line, font=source_font, fill="black")
|
|
|
|
|
|
def _cover_fit(image: Image.Image, size: int) -> Image.Image:
|
|
"""Center-crop to a square, then resize -- mirrors the CSS object-fit: cover behavior."""
|
|
w, h = image.size
|
|
side = min(w, h)
|
|
left = (w - side) // 2
|
|
top = (h - side) // 2
|
|
cropped = image.crop((left, top, left + side, top + side))
|
|
return cropped.resize((size, size), Image.LANCZOS)
|
|
|
|
|
|
def _format_time(seconds) -> str:
|
|
seconds = int(seconds or 0)
|
|
return f"{seconds // 60}:{seconds % 60:02d}"
|