ddf4a6cb29
- 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.
392 lines
15 KiB
Python
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
|