130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
"""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()
|