feat: production-ready Linux & macOS support
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s

- Add `linux` (dbus-python, PyGObject, python-xlib) and `macos`
  (pyobjc) extras to pyproject.toml with sys_platform markers; move
  cross-platform screen-brightness-control + monitorcontrol to base deps.
- build-dist-linux.sh: install `.[linux]`, pkg-config pre-flight for
  dbus-1/glib-2.0, emit a systemd unit with DBUS_SESSION_BUS_ADDRESS +
  XDG_RUNTIME_DIR + ReadWritePaths for ~/.config and ~/.cache so MPRIS
  works and audit-log / thumbnail writes aren't blocked by ProtectHome.
- New build-dist-macos.sh + per-user LaunchAgent installer producing
  MediaServer-vX.Y-macos-{arm64,x86_64}.tar.gz.
- Templated media-server.service updated to match the dist layout with
  proper session-bus env vars and a writable state-dir grant.
- install_linux.sh: drop dead requirements.txt path; install via
  `pip install ".[linux]"` and pre-create the writable state dirs.
- Cross-platform album artwork: abstract MediaController.get_album_art()
  with Linux (mpris:artUrl, file:// + http(s)://) and macOS (Spotify URL)
  impls; routes/media artwork endpoint now awaits the controller.
- LinuxMediaController connects to the session bus lazily — failure no
  longer crashes lifespan startup; MPRIS calls return idle until the bus
  is reachable. Logged once at INFO with a hint about
  `loginctl enable-linger`.
- Startup preflight on Linux warns if DBUS_SESSION_BUS_ADDRESS or
  XDG_RUNTIME_DIR is unset and informs the user when Wayland disables
  the foreground probe.
- /api/media/visualizer/status now reports a per-OS unavailable_reason.
- tray._confirm guarded against ctypes.windll on non-Windows.
- config.example.yaml: per-OS commented script examples; on_turn_off
  default is now a no-op echo (used to silently fail off Windows).
- README: replace stale `pip install -r requirements.txt` instructions
  with the new extras; add systemd lingering doc + troubleshooting
  section; add macOS LaunchAgent section.
- CI: new linux-smoke job (installs `.[linux]`, boots the server under
  dbus-run-session, asserts /api/health). Release workflow gains
  apt-deps step for the Linux build and a best-effort macOS build job.
This commit is contained in:
2026-05-26 12:17:30 +03:00
parent 82710c6457
commit ddf4a6cb29
16 changed files with 823 additions and 109 deletions
+28 -2
View File
@@ -65,7 +65,12 @@ def get_media_controller() -> "MediaController":
def get_current_album_art() -> bytes | None:
"""Get the current album art bytes (Windows only for now)."""
"""Get the current album art bytes (synchronous, Windows-cached path).
Windows pre-populates a module-level cache via the WinRT polling thread,
so this stays sync. For Linux/macOS the controller fetches on demand —
use ``get_current_album_art_async()`` from FastAPI handlers instead.
"""
system = platform.system()
if system == "Windows":
from .windows_media import get_current_album_art as _get_art
@@ -73,6 +78,22 @@ def get_current_album_art() -> bytes | None:
return None
async def get_current_album_art_async() -> bytes | None:
"""Cross-platform album art fetch. Awaits the controller's impl.
Falls back to the sync Windows cache when running on Windows so we don't
pay an extra coroutine hop for the existing path.
"""
system = platform.system()
if system == "Windows":
return get_current_album_art()
controller = get_media_controller()
try:
return await controller.get_album_art()
except Exception: # noqa: BLE001 — art is best-effort; never break the route
return None
def get_audio_devices() -> list[dict[str, str]]:
"""Get list of available audio output devices (Windows only for now)."""
system = platform.system()
@@ -82,4 +103,9 @@ def get_audio_devices() -> list[dict[str, str]]:
return []
__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"]
__all__ = [
"get_media_controller",
"get_current_album_art",
"get_current_album_art_async",
"get_audio_devices",
]
+120 -4
View File
@@ -2,14 +2,23 @@
import asyncio
import logging
import os
import subprocess
import threading
from typing import Any, Optional
from urllib.parse import unquote, urlparse
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
logger = logging.getLogger(__name__)
# Cap remote artwork downloads so a hostile / huge Spotify image can't
# blow up RAM. Real album art rarely exceeds ~2 MB; 8 MB is a comfortable
# upper bound that also covers loss-less PNGs.
_MAX_ART_BYTES = 8 * 1024 * 1024
_ART_FETCH_TIMEOUT = 5.0 # seconds
# Linux-specific imports
try:
import dbus
@@ -35,13 +44,54 @@ class LinuxMediaController(MediaController):
"Linux media control requires dbus-python package. "
"Install with: sudo apt-get install python3-dbus"
)
DBusGMainLoop(set_as_default=True)
self._bus = dbus.SessionBus()
# The session-bus connection is deferred until first use. Connecting
# in __init__ raised during app startup whenever the user's session
# bus wasn't ready yet — common under systemd (service starts
# before logind set up /run/user/<uid>/bus), under SSH-without-X11,
# and in headless CI. Failing here killed the whole lifespan; now
# MPRIS calls return "idle" until the bus appears, and other
# endpoints (health, scripts, browser, …) keep working.
self._bus_lock = threading.Lock()
self._bus = None # type: ignore[assignment]
self._bus_init_logged = False
# Cached art bytes keyed by the mpris:artUrl currently in flight.
# Lock guards the swap from the status thread vs the artwork handler.
self._art_lock = threading.Lock()
self._art_url: Optional[str] = None
self._art_bytes: Optional[bytes] = None
def _get_bus(self):
"""Lazily connect to the session bus; returns None if unavailable."""
if self._bus is not None:
return self._bus
with self._bus_lock:
if self._bus is not None:
return self._bus
try:
DBusGMainLoop(set_as_default=True)
self._bus = dbus.SessionBus()
logger.info("Connected to D-Bus session bus")
return self._bus
except Exception as e:
# Log once at INFO to avoid log spam if every status poll fails.
if not self._bus_init_logged:
logger.info(
"D-Bus session bus not available (%s). "
"MPRIS calls will return 'idle' until DBUS_SESSION_BUS_ADDRESS"
" is set and the bus is reachable. Under systemd, ensure"
" `loginctl enable-linger <user>` is set.",
e,
)
self._bus_init_logged = True
return None
def _get_active_player(self) -> Optional[str]:
"""Find an active MPRIS media player on the bus."""
bus = self._get_bus()
if bus is None:
return None
try:
bus_names = self._bus.list_names()
bus_names = bus.list_names()
mpris_players = [
name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
]
@@ -181,7 +231,15 @@ class LinuxMediaController(MediaController):
if artists:
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
status.album = str(metadata.get("xesam:album", "")) or None
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
art_url = str(metadata.get("mpris:artUrl", "")) or None
status.album_art_url = art_url
# Invalidate cached bytes when the track changes. Real fetch
# happens lazily in get_album_art() so the status hot path
# never blocks on HTTP.
with self._art_lock:
if art_url != self._art_url:
self._art_url = art_url
self._art_bytes = None
length = metadata.get("mpris:length", 0)
if length:
status.duration = int(length) / 1_000_000
@@ -273,3 +331,61 @@ class LinuxMediaController(MediaController):
except Exception as e:
logger.error(f"Failed to open file {file_path}: {e}")
return False
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
"""Resolve an ``mpris:artUrl`` to raw bytes (file://, http(s)://).
Other schemes (data:, ftp:, …) are rejected — we only support the
two cases real-world MPRIS providers use. The HTTP path is capped
at _MAX_ART_BYTES and the file path is read with a size guard so a
symlink to /dev/zero can't OOM the server.
"""
try:
parsed = urlparse(url)
except ValueError:
return None
scheme = parsed.scheme.lower()
if scheme == "file":
path = unquote(parsed.path)
try:
size = os.stat(path).st_size
if size <= 0 or size > _MAX_ART_BYTES:
return None
with open(path, "rb") as f:
return f.read(_MAX_ART_BYTES)
except OSError as e:
logger.debug("Could not read local art %s: %s", path, e)
return None
if scheme in ("http", "https"):
import urllib.error
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
try:
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
# Cap reads to defend against unbounded responses.
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
logger.debug("Could not fetch remote art %s: %s", url, e)
return None
logger.debug("Unsupported art URL scheme: %s", scheme)
return None
async def get_album_art(self) -> Optional[bytes]:
"""Return cached MPRIS art, fetching on first access per track."""
with self._art_lock:
url = self._art_url
cached = self._art_bytes
if cached is not None:
return cached
if not url:
return None
data = await asyncio.to_thread(self._fetch_art_sync, url)
# Store even on None so we don't re-hammer a 404 every second.
with self._art_lock:
if url == self._art_url:
self._art_bytes = data
return data
+60 -1
View File
@@ -3,6 +3,7 @@
import asyncio
import logging
import subprocess
import threading
from typing import Optional
from ..models import MediaState, MediaStatus
@@ -10,10 +11,20 @@ from .media_controller import MediaController
logger = logging.getLogger(__name__)
# Cap remote artwork downloads (Spotify's artwork url is http(s)://).
_MAX_ART_BYTES = 8 * 1024 * 1024
_ART_FETCH_TIMEOUT = 5.0 # seconds
class MacOSMediaController(MediaController):
"""Media controller for macOS using osascript and system commands."""
def __init__(self) -> None:
# Cached art bytes keyed by the active art URL.
self._art_lock = threading.Lock()
self._art_url: Optional[str] = None
self._art_bytes: Optional[bytes] = None
def _run_osascript(self, script: str) -> Optional[str]:
"""Run an AppleScript and return the output."""
try:
@@ -193,12 +204,60 @@ class MacOSMediaController(MediaController):
status.album = info.get("album")
status.duration = info.get("duration")
status.position = info.get("position")
status.album_art_url = info.get("art_url")
art_url = info.get("art_url")
status.album_art_url = art_url
# Track changes invalidate the cached image bytes — actual
# fetch happens lazily in get_album_art().
with self._art_lock:
if art_url != self._art_url:
self._art_url = art_url
self._art_bytes = None
else:
status.state = MediaState.IDLE
return status
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
"""Resolve a Spotify/Music art URL (http(s)://) to bytes.
File-scheme URLs aren't expected on macOS (AppleScript apps return
artwork as remote URLs), so only http(s) is supported.
"""
try:
from urllib.parse import urlparse
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme.lower() not in ("http", "https"):
return None
import urllib.error
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
try:
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
logger.debug("Could not fetch macOS art %s: %s", url, e)
return None
async def get_album_art(self) -> Optional[bytes]:
"""Return cached art bytes, fetching once per track URL."""
with self._art_lock:
url = self._art_url
cached = self._art_bytes
if cached is not None:
return cached
if not url:
return None
data = await asyncio.to_thread(self._fetch_art_sync, url)
with self._art_lock:
if url == self._art_url:
self._art_bytes = data
return data
async def play(self) -> bool:
"""Resume playback using media key simulation."""
# Use system media key
@@ -106,3 +106,12 @@ class MediaController(ABC):
True if successful, False otherwise
"""
pass
async def get_album_art(self) -> bytes | None:
"""Return the current album art bytes, or ``None`` when unavailable.
Default impl returns ``None`` — controllers that can produce art
(Windows via SMTC thumbnail, Linux via mpris:artUrl, macOS via the
Spotify/Music artwork-url field) override this.
"""
return None