Files
media-player-server/media_server/services/linux_media.py
T
alexei.dolgolyov ddf4a6cb29
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s
feat: production-ready Linux & macOS support
- 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.
2026-05-26 12:17:30 +03:00

392 lines
15 KiB
Python

"""Linux media controller using MPRIS D-Bus interface."""
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
from dbus.mainloop.glib import DBusGMainLoop
DBUS_AVAILABLE = True
except ImportError:
DBUS_AVAILABLE = False
logger.warning("D-Bus libraries not available")
class LinuxMediaController(MediaController):
"""Media controller for Linux using MPRIS D-Bus interface."""
MPRIS_PATH = "/org/mpris/MediaPlayer2"
MPRIS_INTERFACE = "org.mpris.MediaPlayer2.Player"
MPRIS_PREFIX = "org.mpris.MediaPlayer2."
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
def __init__(self):
if not DBUS_AVAILABLE:
raise RuntimeError(
"Linux media control requires dbus-python package. "
"Install with: sudo apt-get install python3-dbus"
)
# 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 = bus.list_names()
mpris_players = [
name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
]
if not mpris_players:
return None
# Prefer players that are currently playing
for player in mpris_players:
try:
proxy = self._bus.get_object(player, self.MPRIS_PATH)
props = dbus.Interface(proxy, self.PROPERTIES_INTERFACE)
status = props.Get(self.MPRIS_INTERFACE, "PlaybackStatus")
if status == "Playing":
return player
except Exception:
continue
# Return the first available player
return mpris_players[0]
except Exception as e:
logger.error(f"Failed to get active player: {e}")
return None
def _get_player_interface(self, player_name: str):
"""Get the MPRIS player interface."""
proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
return dbus.Interface(proxy, self.MPRIS_INTERFACE)
def _get_properties_interface(self, player_name: str):
"""Get the properties interface for a player."""
proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
return dbus.Interface(proxy, self.PROPERTIES_INTERFACE)
def _get_property(self, player_name: str, property_name: str) -> Any:
"""Get a property from the player."""
try:
props = self._get_properties_interface(player_name)
return props.Get(self.MPRIS_INTERFACE, property_name)
except Exception as e:
logger.debug(f"Failed to get property {property_name}: {e}")
return None
def _get_volume_pulseaudio(self) -> tuple[int, bool]:
"""Get volume using pactl (PulseAudio/PipeWire)."""
try:
# Get default sink volume
result = subprocess.run(
["pactl", "get-sink-volume", "@DEFAULT_SINK@"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
# Parse volume from output like "Volume: front-left: 65536 / 100% / 0.00 dB"
for part in result.stdout.split("/"):
if "%" in part:
volume = int(part.strip().rstrip("%"))
break
else:
volume = 100
else:
volume = 100
# Get mute status
result = subprocess.run(
["pactl", "get-sink-mute", "@DEFAULT_SINK@"],
capture_output=True,
text=True,
timeout=5,
)
muted = "yes" in result.stdout.lower() if result.returncode == 0 else False
return volume, muted
except Exception as e:
logger.error(f"Failed to get volume via pactl: {e}")
return 100, False
def _set_volume_pulseaudio(self, volume: int) -> bool:
"""Set volume using pactl."""
try:
result = subprocess.run(
["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{volume}%"],
capture_output=True,
timeout=5,
)
return result.returncode == 0
except Exception as e:
logger.error(f"Failed to set volume: {e}")
return False
def _toggle_mute_pulseaudio(self) -> bool:
"""Toggle mute using pactl, returns new mute state."""
try:
result = subprocess.run(
["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"],
capture_output=True,
timeout=5,
)
if result.returncode == 0:
_, muted = self._get_volume_pulseaudio()
return muted
return False
except Exception as e:
logger.error(f"Failed to toggle mute: {e}")
return False
def _sync_get_status(self) -> MediaStatus:
"""Synchronous status read (called from a worker thread)."""
status = MediaStatus()
volume, muted = self._get_volume_pulseaudio()
status.volume = volume
status.muted = muted
player_name = self._get_active_player()
if player_name is None:
status.state = MediaState.IDLE
return status
playback_status = self._get_property(player_name, "PlaybackStatus")
if playback_status == "Playing":
status.state = MediaState.PLAYING
elif playback_status == "Paused":
status.state = MediaState.PAUSED
elif playback_status == "Stopped":
status.state = MediaState.STOPPED
else:
status.state = MediaState.IDLE
metadata = self._get_property(player_name, "Metadata")
if metadata:
status.title = str(metadata.get("xesam:title", "")) or None
artists = metadata.get("xesam:artist", [])
if artists:
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
status.album = str(metadata.get("xesam:album", "")) 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
position = self._get_property(player_name, "Position")
if position is not None:
status.position = int(position) / 1_000_000
status.source = player_name.replace(self.MPRIS_PREFIX, "")
return status
async def get_status(self) -> MediaStatus:
"""Get current media playback status (off the event loop)."""
# pactl + DBus calls each take 5-100ms on a Pi and would block every
# other coroutine on the server. Run them in a worker thread.
return await asyncio.to_thread(self._sync_get_status)
def _call_player(self, method_name: str) -> bool:
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
getattr(player, method_name)()
return True
except Exception as e:
logger.error(f"Failed to call player.{method_name}: {e}")
return False
async def play(self) -> bool:
return await asyncio.to_thread(self._call_player, "Play")
async def pause(self) -> bool:
return await asyncio.to_thread(self._call_player, "Pause")
async def stop(self) -> bool:
return await asyncio.to_thread(self._call_player, "Stop")
async def next_track(self) -> bool:
return await asyncio.to_thread(self._call_player, "Next")
async def previous_track(self) -> bool:
return await asyncio.to_thread(self._call_player, "Previous")
async def set_volume(self, volume: int) -> bool:
return await asyncio.to_thread(self._set_volume_pulseaudio, volume)
async def toggle_mute(self) -> bool:
return await asyncio.to_thread(self._toggle_mute_pulseaudio)
def _sync_seek(self, position: float) -> bool:
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.SetPosition(
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
int(position * 1_000_000),
)
return True
except Exception as e:
logger.error(f"Failed to seek: {e}")
return False
async def seek(self, position: float) -> bool:
return await asyncio.to_thread(self._sync_seek, position)
async def open_file(self, file_path: str) -> bool:
"""Open a media file with the default system player (Linux).
Uses xdg-open to open the file with the default application.
Args:
file_path: Absolute path to the media file
Returns:
True if successful, False otherwise
"""
try:
process = await asyncio.create_subprocess_exec(
'xdg-open', file_path,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL
)
await process.wait()
logger.info(f"Opened file with default player: {file_path}")
return True
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