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.
454 lines
14 KiB
Python
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})
|