"""Bridges mpv/spotifyd MPRIS state to the HA media_player entity via playerctl. playerctl subprocess calls rather than dbus-python bindings: this daemon runs as a system service that starts before the session bus exists and has to survive the compositor (and therefore every MPRIS player) coming and going. A long-lived dbus connection would have to be torn down and re-established around each of those events, whereas a `playerctl` call is stateless — if no player is up it exits non-zero and the bridge simply reports "off" on that tick. The cost is a poll interval instead of signals, which is irrelevant for a media-transport entity in Home Assistant. """ from __future__ import annotations import logging import subprocess import threading log = logging.getLogger(__name__) PLAYER_PRIORITY = "mpv,spotifyd,%any" _STATUS_TO_HA = { "Playing": "playing", "Paused": "paused", "Stopped": "idle", } _METADATA_FORMAT = "{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}" class MprisBridge: def __init__(self, publish_state, env_provider, poll_interval: float = 2.0): self._publish_state = publish_state # playerctl needs DBUS_SESSION_BUS_ADDRESS, which this system service does not # inherit; SwayControl.session_env() derives it from the kiosk user's runtime dir. self._env_provider = env_provider self._poll_interval = poll_interval self._last_state: dict | None = None def _playerctl(self, *args: str) -> str | None: try: result = subprocess.run( ["playerctl", "-p", PLAYER_PRIORITY, *args], env=self._env_provider(), capture_output=True, text=True, timeout=5, check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: log.debug("playerctl %s failed: %s", " ".join(args), exc) return None if result.returncode != 0: return None return result.stdout.strip() def read_state(self) -> dict: status = self._playerctl("status") if status is None: return {"state": "off"} state = {"state": _STATUS_TO_HA.get(status, "idle")} metadata = self._playerctl("metadata", "--format", _METADATA_FORMAT) if metadata: title, artist, album, length, art_url = (metadata.split("\x1f") + [""] * 5)[:5] state["title"] = title state["artist"] = artist state["album"] = album state["art_url"] = art_url if length.isdigit(): state["duration"] = int(length) // 1_000_000 position = self._playerctl("position") if position: try: state["position"] = int(float(position)) except ValueError: pass volume = self._playerctl("volume") if volume: try: state["volume"] = round(float(volume), 3) except ValueError: pass return state def poll_once(self) -> None: state = self.read_state() if state != self._last_state: self._last_state = state self._publish_state(state) def run_forever(self, stop_event: threading.Event) -> None: while not stop_event.is_set(): try: self.poll_once() except Exception: log.exception("MPRIS poll failed") stop_event.wait(self._poll_interval) # --- command side ------------------------------------------------------- def handle_command(self, command: str) -> None: command = command.strip().upper() action = { "PLAY": ("play",), "PAUSE": ("pause",), "PLAY_PAUSE": ("play-pause",), "TOGGLE": ("play-pause",), "STOP": ("stop",), "NEXT": ("next",), "PREVIOUS": ("previous",), "PREV": ("previous",), }.get(command) if action is None: log.warning("ignoring unknown media command %r", command) return log.info("media command %s", command) self._playerctl(*action) self.poll_once() def set_volume(self, level: float) -> None: level = max(0.0, min(1.0, level)) self._playerctl("volume", f"{level:.3f}") self.poll_once()