feat(tui): implement SomaFM terminal radio player
This commit is contained in:
1 parent
941e45a20f
commit
237de2efe9
21 files changed
+4937
No files matched your search
@@ -0,0 +1,420 @@
|
||||
"""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
|
||||
|
||||
def dbus_property(*args: Any, **kwargs: Any): # type: ignore[no-redef]
|
||||
def decorator(function):
|
||||
return 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:
|
||||
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})
|
||||
Reference in new issue
Block a user