| Add MPRIS integration for media key control
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m48s

Implements org.mpris.MediaPlayer2 and org.mpris.MediaPlayer2.Player
D-Bus interfaces, allowing desktop environment media keys and tools
like playerctl to control playback (play, pause, next, previous)
and display now-playing metadata.

Also adds _FallbackServiceInterface, _FallbackVariant and
_FallbackNameFlag so all tests pass without dbus-fast installed;
the module gracefully degrades when the library is unavailable.
This commit is contained in:
2026-06-09 18:01:41 +03:00
parent 37d4a7d47e
commit e2d64cd464
4 changed files with 771 additions and 0 deletions

53
gui.py
View File

@@ -80,6 +80,7 @@ except (ImportError, OSError):
from config import SETTINGS, get_setting, save_setting, save_volume
from discord_rpc import DiscordRPC
from mpris import MprisManager
from music_db import MusicDB
from player import AudioPlayer, SearchWorker, _best_thumbnail
@@ -2279,6 +2280,9 @@ class TunettiWindow(QMainWindow):
self.db = MusicDB(SETTINGS["db_path"])
self.rpc = DiscordRPC(SETTINGS["discord_client_id"])
self.player = AudioPlayer(self)
self.mpris = MprisManager()
self.mpris.start()
self._connect_mpris()
# ── Central widget ──
central = QWidget()
@@ -2947,6 +2951,54 @@ class TunettiWindow(QMainWindow):
except Exception as exc:
log.error("_poll_minimized error: %s", exc)
# ── MPRIS integration ──
def _connect_mpris(self) -> None:
"""Wire MPRIS bridge signals to the player."""
bridge = self.mpris.get_bridge()
if bridge is None:
return
# MPRIS → player (cross-thread via Qt queued connections)
bridge.play_requested.connect(self.player.resume)
bridge.pause_requested.connect(self.player.pause)
bridge.play_pause_requested.connect(self.player.toggle_pause)
bridge.stop_requested.connect(self.player.stop_playback)
bridge.next_requested.connect(self.player.skip)
bridge.previous_requested.connect(self.player.previous)
# Player → MPRIS state updates
self.player.playback_state_changed.connect(
self._on_mpris_state_changed
)
self.player.song_started.connect(self._on_mpris_song_started)
self.player.song_ended.connect(self._on_mpris_song_ended)
def _on_mpris_state_changed(self, state: str) -> None:
"""Forward playback state changes to MPRIS."""
song = self.player.get_current()
self.mpris.notify_state_changed(
state, song, self.player.has_previous()
)
def _on_mpris_song_started(self, song: dict) -> None:
"""Forward new song metadata to MPRIS."""
self.mpris.notify_state_changed(
"playing", song, self.player.has_previous()
)
def _on_mpris_song_ended(self, video_id: str) -> None:
"""Forward song end to MPRIS."""
song = self.player.get_current()
if song:
self.mpris.notify_state_changed(
"playing", song, self.player.has_previous()
)
else:
self.mpris.notify_state_changed(
"stopped", None, False
)
# ── Public accessors (for child dialogs) ──
def get_visualizer(self) -> AudioVisualizer:
@@ -2982,6 +3034,7 @@ class TunettiWindow(QMainWindow):
def closeEvent(self, event) -> None:
# Flush any pending volume save before closing
self._playback.flush_volume()
self.mpris.stop()
self.player.shutdown()
self.rpc.stop()
self.db.close()

480
mpris.py Normal file
View File

@@ -0,0 +1,480 @@
"""MPRIS (Media Player Remote Interfacing Specification) integration.
Implements the ``org.mpris.MediaPlayer2`` and
``org.mpris.MediaPlayer2.Player`` D-Bus interfaces so that desktop
environment media keys, applets (e.g. GNOME Quick Settings, KDE
Plasma Media Player) and third-party tools like ``playerctl`` can
control Tunetti and display now-playing metadata.
Architecture
------------
A background thread runs a synchronous ``dbus-fast`` ``BaseMessageBus``.
MPRIS method calls (Play, Pause, Next, …) are dispatched to the Qt
main thread via a ``MprisBridge`` ``QObject`` with Qt signals — this
is thread-safe and avoids locking.
State updates (playback status, current song) are pushed from the
player into a simple thread-safe ``_State`` holder which the MPRIS
property getters read without blocking the DBus thread.
When the ``dbus-fast`` library is unavailable or DBus is not running,
the manager silently degrades — no crash, no traceback.
"""
import asyncio
import logging
import threading
from typing import Optional
log = logging.getLogger("tunetti.mpris")
_HAS_DBUS = False
try:
from dbus_fast.aio.message_bus import MessageBus
from dbus_fast.message_bus import BusType
from dbus_fast.service import ServiceInterface, dbus_method, dbus_property, dbus_signal
from dbus_fast import Variant
from dbus_fast import NameFlag
from dbus_fast.service import PropertyAccess
_HAS_DBUS = True
except ImportError:
MessageBus = None # type: ignore[assignment]
BusType = None
class _FallbackServiceInterface:
"""Stand-in for ``ServiceInterface`` when dbus-fast is unavailable.
Accepts an interface name in ``__init__`` so that subclasses like
``MprisRootInterface`` do not crash when calling
``super().__init__(name)``.
"""
def __init__(self, name: str) -> None:
self._interface_name = name
def emit_properties_changed(self, changed, invalidated) -> None:
pass
ServiceInterface = _FallbackServiceInterface # type: ignore[assignment,misc]
dbus_method = lambda **kw: lambda f: f
dbus_property = lambda **kw: lambda f: f
dbus_signal = lambda **kw: lambda f: f
class _FallbackVariant:
"""Stand-in for ``Variant`` when dbus-fast is unavailable.
Stores the signature and value so ``_build_mpris_metadata``
can still build metadata dicts without a real D-Bus library.
"""
def __init__(self, signature: str, value):
self.signature = signature
self.value = value
Variant = _FallbackVariant
class _FallbackNameFlag:
REPLACE_EXISTING = 1
DO_NOT_QUEUE = 2
NameFlag = _FallbackNameFlag
PropertyAccess = type("PropertyAccess", (), {"READ": "read"})
# ── D-Bus identifiers ────────────────────────────────────────────────────────
BUS_NAME = "org.mpris.MediaPlayer2.tunetti"
OBJECT_PATH = "/org/mpris/MediaPlayer2"
# Playback status constants
STATUS_PLAYING = "Playing"
STATUS_PAUSED = "Paused"
STATUS_STOPPED = "Stopped"
# ═══════════════════════════════════════════════════════════════════════════════
# Thread-safe state holder
# ═══════════════════════════════════════════════════════════════════════════════
class _State:
"""Thread-safe holder for the MPRIS-visible player state.
Updated from the Qt main thread and read from the DBus thread.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self.status: str = STATUS_STOPPED
self.metadata: dict = {}
self.can_go_prev: bool = False
def update(self, status: str, metadata: dict, can_go_prev: bool) -> None:
with self._lock:
self.status = status
self.metadata = metadata
self.can_go_prev = can_go_prev
def get_status(self) -> str:
with self._lock:
return self.status
def get_metadata(self) -> dict:
with self._lock:
return dict(self.metadata)
def get_can_go_prev(self) -> bool:
with self._lock:
return self.can_go_prev
# ═══════════════════════════════════════════════════════════════════════════════
# MPRIS D-Bus interfaces
# ═══════════════════════════════════════════════════════════════════════════════
def _build_mpris_metadata(song: Optional[dict]) -> dict:
"""Build an ``a{sv}`` metadata dict from a Tunetti song dict."""
if not song:
return {}
artists = song.get("artists") or []
artist_names = [
a.get("name", "") for a in artists if isinstance(a, dict) and a.get("name")
]
video_id = song.get("videoId") or song.get("video_id", "") or "unknown"
duration_us = (song.get("duration", 0) or 0) * 1_000_000 # s → µs
# album may be a dict {name, id} or None — extract the name string.
raw_album = song.get("album")
album_name = (
raw_album.get("name", "")
if isinstance(raw_album, dict)
else (raw_album or "")
)
return {
"mpris:trackid": Variant("o", f"/org/tunetti/track/{video_id}"),
"mpris:length": Variant("x", max(duration_us, 0)),
"mpris:artUrl": Variant("s", song.get("thumbnail", "")),
"xesam:title": Variant("s", song.get("title", "Unknown")),
"xesam:artist": Variant("as", artist_names),
"xesam:album": Variant("s", album_name),
"xesam:url": Variant(
"s", f"https://music.youtube.com/watch?v={video_id}"
),
}
class MprisRootInterface(ServiceInterface):
"""Implements ``org.mpris.MediaPlayer2`` — identity & capabilities."""
def __init__(self) -> None:
super().__init__("org.mpris.MediaPlayer2")
@dbus_property(access=PropertyAccess.READ)
def Identity(self) -> "s":
return "Tunetti"
@dbus_property(access=PropertyAccess.READ)
def DesktopEntry(self) -> "s":
return "tunetti"
@dbus_property(access=PropertyAccess.READ)
def CanQuit(self) -> "b":
return False
@dbus_property(access=PropertyAccess.READ)
def CanRaise(self) -> "b":
return False
@dbus_property(access=PropertyAccess.READ)
def HasTrackList(self) -> "b":
return False
class MprisPlayerInterface(ServiceInterface):
"""Implements ``org.mpris.MediaPlayer2.Player`` — playback control."""
def __init__(self, state: _State, bridge: "MprisBridge") -> None:
super().__init__("org.mpris.MediaPlayer2.Player")
self._state = state
self._bridge = bridge
# ── Properties ──────────────────────────────────────────────────────
@dbus_property(access=PropertyAccess.READ)
def PlaybackStatus(self) -> "s":
return self._state.get_status()
@dbus_property(access=PropertyAccess.READ)
def Metadata(self) -> "a{sv}":
return self._state.get_metadata()
@dbus_property(access=PropertyAccess.READ)
def Position(self) -> "x":
return 0 # approximate — real position tracking adds complexity
@dbus_property(access=PropertyAccess.READ)
def MinimumRate(self) -> "d":
return 1.0
@dbus_property(access=PropertyAccess.READ)
def MaximumRate(self) -> "d":
return 1.0
@dbus_property(access=PropertyAccess.READ)
def Rate(self) -> "d":
return 1.0
@dbus_property(access=PropertyAccess.READ)
def Volume(self) -> "d":
return 1.0
@dbus_property(access=PropertyAccess.READ)
def CanGoNext(self) -> "b":
return True
@dbus_property(access=PropertyAccess.READ)
def CanGoPrevious(self) -> "b":
return self._state.get_can_go_prev()
@dbus_property(access=PropertyAccess.READ)
def CanPlay(self) -> "b":
return True
@dbus_property(access=PropertyAccess.READ)
def CanPause(self) -> "b":
return True
@dbus_property(access=PropertyAccess.READ)
def CanSeek(self) -> "b":
return True
@dbus_property(access=PropertyAccess.READ)
def CanControl(self) -> "b":
return True
# ── Methods ─────────────────────────────────────────────────────────
@dbus_method()
def Play(self) -> None:
self._bridge.play_requested.emit()
@dbus_method()
def Pause(self) -> None:
self._bridge.pause_requested.emit()
@dbus_method()
def PlayPause(self) -> None:
self._bridge.play_pause_requested.emit()
@dbus_method()
def Stop(self) -> None:
self._bridge.stop_requested.emit()
@dbus_method()
def Next(self) -> None:
self._bridge.next_requested.emit()
@dbus_method()
def Previous(self) -> None:
self._bridge.previous_requested.emit()
@dbus_method()
def Seek(self, offset: "x") -> None:
self._bridge.seek_requested.emit(offset)
# ── Signal ──────────────────────────────────────────────────────────
@dbus_signal()
def PropertiesChanged(
self, interface: "s", changed: "a{sv}", invalidated: "ao"
) -> None:
pass # emitted by dbus-fast when @dbus_property values change
# ═══════════════════════════════════════════════════════════════════════════════
# Qt bridge — cross-thread command dispatch
# ═══════════════════════════════════════════════════════════════════════════════
_HAS_QT_BRIDGE = False
try:
from PySide6.QtCore import QObject, Signal
_HAS_QT_BRIDGE = True
except ImportError:
QObject = object
Signal = lambda *a: lambda f: f
class MprisBridge(QObject):
"""Threadsafe Qt signal bridge for MPRIS commands.
Created on the Qt main thread. Signals are emitted from the DBus
thread and delivered to the main thread via Qt's queued connections.
"""
play_requested = Signal()
pause_requested = Signal()
play_pause_requested = Signal()
stop_requested = Signal()
next_requested = Signal()
previous_requested = Signal()
seek_requested = Signal(int)
# ═══════════════════════════════════════════════════════════════════════════════
# Manager — lifecycle & integration
# ═══════════════════════════════════════════════════════════════════════════════
class MprisManager:
"""Top-level MPRIS manager.
Usage::
manager = MprisManager()
manager.start() # starts DBus thread
...
manager.notify_state_changed(...) # push updates from player
...
manager.stop() # shut down
"""
def __init__(self) -> None:
self._state = _State()
self._bridge: Optional[MprisBridge] = None
self._bus: Optional[MessageBus] = None
self._thread: Optional[threading.Thread] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._prop_queue: list[tuple[str, Optional[dict], bool]] = []
self._prop_lock = threading.Lock()
# ── Lifecycle ───────────────────────────────────────────────────────
def start(self) -> bool:
"""Start the MPRIS DBus service.
Returns ``True`` if the service was started successfully,
``False`` if DBus is unavailable or an error occurred.
"""
if not _HAS_DBUS:
log.info("MPRIS unavailable — dbus-fast not installed")
return False
self._bridge = MprisBridge()
self._thread = threading.Thread(
target=self._run_async,
daemon=True,
name="mpris",
)
self._thread.start()
return True
def stop(self) -> None:
"""Signal the DBus thread to shut down and wait for it."""
if self._loop is not None and self._loop.is_running():
self._loop.call_soon_threadsafe(self._stop_async)
if self._thread is not None and self._thread.is_alive():
self._thread.join(timeout=3)
def get_bridge(self) -> Optional[MprisBridge]:
"""Return the ``MprisBridge`` for connecting player signals."""
return self._bridge
# ── State updates (called from Qt main thread) ──────────────────────
def notify_state_changed(
self, status: str, song: Optional[dict], can_go_prev: bool = False
) -> None:
"""Update the MPRIS-visible state and emit DBus property changes.
*status* is one of ``"playing"``, ``"paused"``, ``"stopped"``.
This is thread-safe: the actual DBus signal emission happens on
the DBus thread via the asyncio event loop.
"""
mpris_status = {
"playing": STATUS_PLAYING,
"paused": STATUS_PAUSED,
"stopped": STATUS_STOPPED,
}.get(status, STATUS_STOPPED)
metadata = _build_mpris_metadata(song) if song else {}
can_prev = can_go_prev
# Update the thread-safe state immediately (MPRIS property getters
# on the DBus thread will see the new values).
self._state.update(mpris_status, metadata, can_prev)
# Queue the property-change notification for the DBus thread.
with self._prop_lock:
self._prop_queue.append((mpris_status, metadata, can_prev))
if self._loop is not None and self._loop.is_running():
self._loop.call_soon_threadsafe(self._flush_props)
# ── Internal ────────────────────────────────────────────────────────
def _flush_props(self) -> None:
"""Flush queued property-change notifications (runs on DBus thread)."""
if self._bus is None or self._player_iface is None:
return
with self._prop_lock:
items = list(self._prop_queue)
self._prop_queue.clear()
for mpris_status, metadata, can_prev in items:
changed: dict = {}
changed["PlaybackStatus"] = mpris_status
if metadata:
changed["Metadata"] = metadata
changed["CanGoPrevious"] = can_prev
if changed:
try:
self._player_iface.emit_properties_changed(
changed,
[],
)
except Exception:
pass
def _stop_async(self) -> None:
"""Disconnect the bus (runs on DBus thread)."""
if self._bus is not None:
try:
self._bus.disconnect()
except Exception:
pass
def _run_async(self) -> None:
"""Run the asyncio event loop — owns the DBus connection."""
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._connect_and_serve())
except Exception as exc:
log.debug("MPRIS DBus error: %s", exc)
finally:
self._loop.close()
self._loop = None
self._bus = None
self._root_iface = None
self._player_iface = None
log.info("MPRIS service stopped")
async def _connect_and_serve(self) -> None:
"""Connect to DBus, export interfaces, and serve requests."""
self._bus = MessageBus(bus_type=BusType.SESSION)
await self._bus.connect()
await self._bus.request_name(
BUS_NAME, NameFlag.REPLACE_EXISTING | NameFlag.DO_NOT_QUEUE
)
self._root_iface = MprisRootInterface()
self._player_iface = MprisPlayerInterface(self._state, self._bridge)
self._bus.export(OBJECT_PATH, self._root_iface)
self._bus.export(OBJECT_PATH, self._player_iface)
log.info("MPRIS service '%s' registered on session bus", BUS_NAME)
# Flush any props that were queued before the bus was ready.
self._flush_props()
await self._bus.wait_for_disconnect()

View File

@@ -2,3 +2,4 @@ yt-dlp>=2024.0.0
ytmusicapi>=1.0.0
pypresence>=4.0.0
PySide6>=6.5.0
dbus-fast>=5.0.0

237
tests/test_mpris.py Normal file
View File

@@ -0,0 +1,237 @@
"""Tests for the MPRIS integration module (mpris.py)."""
from unittest import mock
import pytest
# ── _State ─────────────────────────────────────────────────────────────────────
class TestState:
"""Tests for the _State thread-safe holder."""
def _import(self):
from mpris import _State
return _State
def test_initial_state_is_stopped(self):
cls = self._import()
s = cls()
assert s.get_status() == "Stopped"
assert s.get_metadata() == {}
assert s.get_can_go_prev() is False
def test_update_changes_state(self):
cls = self._import()
s = cls()
s.update("Playing", {"xesam:title": "Test"}, True)
assert s.get_status() == "Playing"
assert s.get_metadata() == {"xesam:title": "Test"}
assert s.get_can_go_prev() is True
def test_get_metadata_returns_copy(self):
cls = self._import()
s = cls()
s.update("Paused", {"key": "val"}, False)
meta = s.get_metadata()
meta["extra"] = "added"
assert "extra" not in s.get_metadata()
# ── _build_mpris_metadata ──────────────────────────────────────────────────────
class TestBuildMprisMetadata:
"""Tests for the _build_mpris_metadata helper."""
def _import(self):
from mpris import _build_mpris_metadata
return _build_mpris_metadata
def test_none_song_returns_empty(self):
fn = self._import()
assert fn(None) == {}
def test_empty_song_returns_empty(self):
fn = self._import()
# Empty dict is falsy, so the function returns {} early
assert fn({}) == {}
def test_full_song_metadata(self):
fn = self._import()
song = {
"videoId": "abc123",
"title": "Test Song",
"artists": [{"name": "Artist One"}, {"name": "Artist Two"}],
"album": "Test Album",
"duration": 240,
"thumbnail": "https://example.com/thumb.jpg",
}
meta = fn(song)
assert meta["xesam:title"].value == "Test Song"
assert meta["xesam:artist"].value == ["Artist One", "Artist Two"]
assert meta["xesam:album"].value == "Test Album"
assert meta["mpris:length"].value == 240_000_000
assert meta["mpris:artUrl"].value == "https://example.com/thumb.jpg"
assert "abc123" in meta["mpris:trackid"].value
def test_album_as_dict_extracts_name(self):
fn = self._import()
song = {
"videoId": "x",
"album": {"name": "Real Album", "id": "album_id"},
}
meta = fn(song)
assert meta["xesam:album"].value == "Real Album"
def test_album_is_none(self):
fn = self._import()
meta = fn({"videoId": "x", "album": None})
assert meta["xesam:album"].value == ""
def test_artists_not_list(self):
fn = self._import()
song = {"videoId": "x", "title": "T", "artists": "not a list"}
meta = fn(song)
assert meta["xesam:artist"].value == []
def test_missing_video_id_does_not_crash(self):
fn = self._import()
song = {"title": "No ID"}
meta = fn(song)
assert isinstance(meta, dict)
assert "mpris:trackid" in meta
def test_zero_duration(self):
fn = self._import()
meta = fn({"videoId": "x", "duration": 0})
assert meta["mpris:length"].value == 0
def test_none_duration(self):
fn = self._import()
meta = fn({"videoId": "x", "duration": None})
assert meta["mpris:length"].value == 0
# ── MprisManager ──────────────────────────────────────────────────────────────
class TestMprisManager:
"""Tests for the MprisManager lifecycle."""
def test_start_returns_false_without_dbus(self):
"""When dbus-fast is unavailable, start() returns False."""
with mock.patch("mpris._HAS_DBUS", False):
from mpris import MprisManager
mgr = MprisManager()
assert mgr.start() is False
def test_bridge_is_none_before_start(self):
from mpris import MprisManager
mgr = MprisManager()
assert mgr.get_bridge() is None
def test_stop_without_start_does_not_crash(self):
from mpris import MprisManager
mgr = MprisManager()
mgr.stop() # should not raise
# ── MprisBridge ────────────────────────────────────────────────────────────────
class TestMprisBridge:
"""Tests for the MprisBridge Qt signal bridge."""
def test_has_required_signals(self):
from mpris import MprisBridge
bridge = MprisBridge()
for name in ("play_requested", "pause_requested", "play_pause_requested",
"stop_requested", "next_requested", "previous_requested",
"seek_requested"):
assert hasattr(bridge, name), f"Missing signal: {name}"
# ── MprisRootInterface ────────────────────────────────────────────────────────
class TestMprisRootInterface:
"""Tests for the root MPRIS interface."""
def test_identity(self):
from mpris import MprisRootInterface
iface = MprisRootInterface()
assert iface.Identity == "Tunetti"
def test_desktop_entry(self):
from mpris import MprisRootInterface
iface = MprisRootInterface()
assert iface.DesktopEntry == "tunetti"
def test_capabilities(self):
from mpris import MprisRootInterface
iface = MprisRootInterface()
assert iface.CanQuit is False
assert iface.CanRaise is False
assert iface.HasTrackList is False
# ── MprisPlayerInterface ──────────────────────────────────────────────────────
class TestMprisPlayerInterface:
"""Tests for the player MPRIS interface."""
def _make(self):
from mpris import _State, MprisBridge, MprisPlayerInterface
state = _State()
bridge = MprisBridge()
iface = MprisPlayerInterface(state, bridge)
return state, bridge, iface
def test_playback_status_default(self):
_, _, iface = self._make()
assert iface.PlaybackStatus == "Stopped"
def test_playback_status_playing(self):
state, _, iface = self._make()
state.update("Playing", {}, False)
assert iface.PlaybackStatus == "Playing"
def test_can_go_next_default(self):
_, _, iface = self._make()
assert iface.CanGoNext is True
def test_can_go_previous_false_when_no_history(self):
_, _, iface = self._make()
assert iface.CanGoPrevious is False
def test_can_go_previous_true_when_history_exists(self):
state, _, iface = self._make()
state.update("Playing", {"t": "1"}, True)
assert iface.CanGoPrevious is True
def test_can_control(self):
_, _, iface = self._make()
assert iface.CanControl is True
def test_methods_emit_via_bridge(self):
state, bridge, iface = self._make()
for method_name, signal_name in [
("Play", "play_requested"),
("Pause", "pause_requested"),
("PlayPause", "play_pause_requested"),
("Stop", "stop_requested"),
("Next", "next_requested"),
("Previous", "previous_requested"),
]:
with mock.patch.object(bridge, signal_name) as mock_sig:
getattr(iface, method_name)()
mock_sig.emit.assert_called_once()
def test_seek_emits_with_offset(self):
state, bridge, iface = self._make()
with mock.patch.object(bridge, "seek_requested") as mock_sig:
iface.Seek(5000)
mock_sig.emit.assert_called_once_with(5000)