"""MPRIS (org.mpris.MediaPlayer2) service. This is what makes GNOME/KDE media widgets, lock screens, ``playerctl``, ``media-control`` etc. show the station, the current artist/title/album and the album art, and lets them play/pause, skip stations and change the volume. The service is optional: if dbus-next is missing or there is no session bus, :attr:`MprisService.available` simply stays ``False`` and the TUI carries on. """ from __future__ import annotations import asyncio import contextlib import hashlib import logging import os import queue import threading from typing import Any try: # pragma: no cover - exercised implicitly on systems without dbus from dbus_next import PropertyAccess, Variant from dbus_next.aio import MessageBus from dbus_next.service import ServiceInterface, dbus_property, method DBUS_AVAILABLE = True except Exception: # pragma: no cover DBUS_AVAILABLE = False class PropertyAccess: # type: ignore[no-redef] READ = "read" READWRITE = "readwrite" class Variant: # type: ignore[no-redef] def __init__(self, signature: str, value: Any) -> None: self.signature = signature self.value = value class ServiceInterface: # type: ignore[no-redef] def __init__(self, name: str) -> None: self.name = name def emit_properties_changed(self, *args: Any, **kwargs: Any) -> None: return None class _StubProperty: # type: ignore[no-redef] """Stand-in for ``dbus_next.service.dbus_property`` on D-Bus-less hosts. It has to behave like a descriptor that also supports ``.setter()``, because the interfaces below define read/write properties with the usual decorator idiom. Importing this module must never fail just because there is no session bus (macOS, Windows, minimal containers). """ def __init__(self, fget: Any = None) -> None: self.fget = fget self.fset: Any = None def getter(self, fget: Any) -> "_StubProperty": self.fget = fget return self def setter(self, fset: Any) -> "_StubProperty": self.fset = fset return self def __get__(self, obj: Any, objtype: Any = None) -> Any: if obj is None or self.fget is None: return self return self.fget(obj) def __set__(self, obj: Any, value: Any) -> None: if self.fset is not None: self.fset(obj, value) def dbus_property(*args: Any, **kwargs: Any): # type: ignore[no-redef] def decorator(function: Any) -> _StubProperty: return _StubProperty(function) return decorator def method(*args: Any, **kwargs: Any): # type: ignore[no-redef] def decorator(function): return function return decorator LOGGER = logging.getLogger(__name__) def build_metadata( *, station: str, title: str, artist: str, album: str = "", art_url: str = "", stream_url: str = "", track_key: str = "", ) -> dict[str, Any]: digest = hashlib.sha1( (track_key or f"{station}|{artist}|{title}").encode("utf-8") ).hexdigest()[:24] metadata: dict[str, Any] = { "mpris:trackid": Variant("o", f"/org/beefyplayer/track/{digest}"), "xesam:title": Variant("s", title or station or "Beefy Player"), "xesam:artist": Variant("as", [artist] if artist else ["Beefy Player"]), "xesam:comment": Variant( "as", [f"Beefy Player ยท {station}"] if station else ["Beefy Player"] ), } if album: metadata["xesam:album"] = Variant("s", album) if art_url.startswith(("http://", "https://", "file://")): metadata["mpris:artUrl"] = Variant("s", art_url) if stream_url.startswith(("http://", "https://")): metadata["xesam:url"] = Variant("s", stream_url) return metadata class _RootInterface(ServiceInterface): def __init__(self, service: "MprisService") -> None: super().__init__("org.mpris.MediaPlayer2") self._service = service @dbus_property(name="Identity", access=PropertyAccess.READ) def Identity(self) -> "s": return "Beefy Player" @dbus_property(name="DesktopEntry", access=PropertyAccess.READ) def DesktopEntry(self) -> "s": return "beefy-player" @dbus_property(name="CanQuit", access=PropertyAccess.READ) def CanQuit(self) -> "b": return True @dbus_property(name="CanRaise", access=PropertyAccess.READ) def CanRaise(self) -> "b": return False @dbus_property(name="HasTrackList", access=PropertyAccess.READ) def HasTrackList(self) -> "b": return False @dbus_property(name="SupportedUriSchemes", access=PropertyAccess.READ) def SupportedUriSchemes(self) -> "as": return ["http", "https"] @dbus_property(name="SupportedMimeTypes", access=PropertyAccess.READ) def SupportedMimeTypes(self) -> "as": return ["audio/mpeg", "audio/aac", "audio/aacp", "audio/ogg"] @method(name="Raise") def Raise(self): return None @method(name="Quit") def Quit(self): self._service.actions.put("quit") class _PlayerInterface(ServiceInterface): def __init__(self, service: "MprisService") -> None: super().__init__("org.mpris.MediaPlayer2.Player") self._service = service self._playback_status = "Stopped" self._metadata: dict[str, Any] = {} self._volume = 0.7 # ----- properties -------------------------------------------------- # @dbus_property(name="PlaybackStatus", access=PropertyAccess.READ) def PlaybackStatus(self) -> "s": return self._playback_status @dbus_property(name="LoopStatus", access=PropertyAccess.READWRITE) def LoopStatus(self) -> "s": return "None" @LoopStatus.setter def LoopStatus(self, value: "s"): return None @dbus_property(name="Rate", access=PropertyAccess.READ) def Rate(self) -> "d": return 1.0 @dbus_property(name="Shuffle", access=PropertyAccess.READWRITE) def Shuffle(self) -> "b": return False @Shuffle.setter def Shuffle(self, value: "b"): return None @dbus_property(name="Metadata", access=PropertyAccess.READ) def Metadata(self) -> "a{sv}": return self._metadata @dbus_property(name="Volume", access=PropertyAccess.READWRITE) def Volume(self) -> "d": return self._volume @Volume.setter def Volume(self, value: "d"): value = max(0.0, min(1.0, float(value))) self._volume = value self._service.actions.put(f"volume:{round(value * 100)}") @dbus_property(name="Position", access=PropertyAccess.READ) def Position(self) -> "x": return 0 @dbus_property(name="MinimumRate", access=PropertyAccess.READ) def MinimumRate(self) -> "d": return 1.0 @dbus_property(name="MaximumRate", access=PropertyAccess.READ) def MaximumRate(self) -> "d": return 1.0 @dbus_property(name="CanGoNext", access=PropertyAccess.READ) def CanGoNext(self) -> "b": return True @dbus_property(name="CanGoPrevious", access=PropertyAccess.READ) def CanGoPrevious(self) -> "b": return True @dbus_property(name="CanPlay", access=PropertyAccess.READ) def CanPlay(self) -> "b": return True @dbus_property(name="CanPause", access=PropertyAccess.READ) def CanPause(self) -> "b": return True @dbus_property(name="CanSeek", access=PropertyAccess.READ) def CanSeek(self) -> "b": return False @dbus_property(name="CanControl", access=PropertyAccess.READ) def CanControl(self) -> "b": return True # ----- methods ----------------------------------------------------- # @method(name="Play") def Play(self): self._service.actions.put("play") @method(name="Pause") def Pause(self): self._service.actions.put("pause") @method(name="PlayPause") def PlayPause(self): self._service.actions.put("toggle") @method(name="Stop") def Stop(self): self._service.actions.put("stop") @method(name="Next") def Next(self): self._service.actions.put("next") @method(name="Previous") def Previous(self): self._service.actions.put("prev") @method(name="Seek") def Seek(self, offset: "x"): return None @method(name="SetPosition") def SetPosition(self, track_id: "o", position: "x"): return None @method(name="OpenUri") def OpenUri(self, uri: "s"): return None class MprisService: #: Status-bar name for this backend (see :mod:`beefy_player.mediasession`). label = "mpris" def __init__( self, actions: "queue.Queue[str]", enabled: bool = True ) -> None: self.actions = actions self.enabled = bool(enabled) and DBUS_AVAILABLE self.available = False self.error = "" self._thread: threading.Thread | None = None self._loop: asyncio.AbstractEventLoop | None = None self._shutdown: asyncio.Event | None = None self._bus: Any = None self._root: _RootInterface | None = None self._player: _PlayerInterface | None = None self._volume = 0.7 self._status = "Stopped" self._metadata: dict[str, Any] = {} self.bus_name = ( f"org.mpris.MediaPlayer2.beefyplayer.instance{os.getpid()}" ) # ------------------------------------------------------------------ # # lifecycle # ------------------------------------------------------------------ # def start(self) -> None: if not self.enabled: return self._thread = threading.Thread( target=self._thread_main, name="beefy-player-mpris", daemon=True ) self._thread.start() def _thread_main(self) -> None: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self._loop = loop try: loop.run_until_complete(self._serve()) except Exception as exc: self.error = str(exc) or exc.__class__.__name__ LOGGER.debug("MPRIS unavailable: %s", exc) finally: self.available = False try: pending = asyncio.all_tasks(loop) for task in pending: task.cancel() if pending: loop.run_until_complete( asyncio.gather(*pending, return_exceptions=True) ) except Exception: pass loop.close() self._loop = None async def _serve(self) -> None: self._shutdown = asyncio.Event() bus = await MessageBus().connect() self._bus = bus self._root = _RootInterface(self) self._player = _PlayerInterface(self) bus.export("/org/mpris/MediaPlayer2", self._root) bus.export("/org/mpris/MediaPlayer2", self._player) await bus.request_name(self.bus_name) self.available = True # Push whatever we already know: the UI may have produced metadata # before the bus name was acquired. if self._metadata: await self._apply_metadata(self._metadata) await self._apply_status(self._status) await self._apply_volume(self._volume) await self._shutdown.wait() with contextlib.suppress(Exception): bus.disconnect() def stop(self) -> None: loop = self._loop if loop is not None and not loop.is_closed(): shutdown = self._shutdown if shutdown is not None: with contextlib.suppress(RuntimeError): loop.call_soon_threadsafe(shutdown.set) thread = self._thread if thread is not None and thread.is_alive(): thread.join(timeout=2.0) @property def status_label(self) -> str: if not self.enabled: return "off" if self.available: return "on" return "error" if self.error else "off" # ------------------------------------------------------------------ # # updates from the app (thread safe) # ------------------------------------------------------------------ # def _submit(self, factory, *args: Any) -> None: if not self.available: return loop = self._loop if loop is None or loop.is_closed(): return with contextlib.suppress(RuntimeError): loop.call_soon_threadsafe(lambda: asyncio.ensure_future(factory(*args))) def set_status(self, status: str) -> None: if status not in ("Playing", "Paused", "Stopped"): return self._status = status self._submit(self._apply_status, status) def set_volume(self, ratio: float) -> None: self._volume = max(0.0, min(1.0, float(ratio))) self._submit(self._apply_volume, self._volume) def set_track( self, *, station: str, title: str, artist: str, album: str = "", art_url: str = "", stream_url: str = "", track_key: str = "", ) -> None: self._metadata = build_metadata( station=station, title=title, artist=artist, album=album, art_url=art_url, stream_url=stream_url, track_key=track_key, ) self._submit(self._apply_metadata, self._metadata) async def _apply_status(self, status: str) -> None: player = self._player if player is None or player._playback_status == status: return player._playback_status = status player.emit_properties_changed({"PlaybackStatus": status}) async def _apply_volume(self, ratio: float) -> None: player = self._player if player is None: return player._volume = ratio player.emit_properties_changed({"Volume": ratio}) async def _apply_metadata(self, metadata: dict[str, Any]) -> None: player = self._player if player is None: return player._metadata = metadata player.emit_properties_changed({"Metadata": metadata})