Files
beefy-player/somafm_tui/mpris.py
T
beefcarver d83a9ef03d fix(mpris): keep the service importable without dbus-next
The stub dbus_property() returned a bare function, so the read/write
properties (@Volume.setter and friends) raised AttributeError at import
time. That made any host without dbus-next -- macOS, Windows, minimal
containers -- crash before the TUI could start, because app.py imports
this module unconditionally.

Replace the stub with a descriptor that supports .getter()/.setter(), and
give the service a short label for the status bar.
2026-10-30 18:42:50 -04:00

454 lines
14 KiB
Python

"""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/somafm/track/{digest}"),
"xesam:title": Variant("s", title or station or "SomaFM"),
"xesam:artist": Variant("as", [artist] if artist else ["SomaFM"]),
"xesam:comment": Variant("as", [f"SomaFM · {station}"] if station else ["SomaFM"]),
}
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 "SomaFM TUI"
@dbus_property(name="DesktopEntry", access=PropertyAccess.READ)
def DesktopEntry(self) -> "s":
return "somafm-tui"
@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:`somafm_tui.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.somafmtui.instance{os.getpid()}"
)
# ------------------------------------------------------------------ #
# lifecycle
# ------------------------------------------------------------------ #
def start(self) -> None:
if not self.enabled:
return
self._thread = threading.Thread(
target=self._thread_main, name="somafm-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})