✨ | Add MPRIS integration for media key control
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m48s
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:
53
gui.py
53
gui.py
@@ -80,6 +80,7 @@ except (ImportError, OSError):
|
|||||||
|
|
||||||
from config import SETTINGS, get_setting, save_setting, save_volume
|
from config import SETTINGS, get_setting, save_setting, save_volume
|
||||||
from discord_rpc import DiscordRPC
|
from discord_rpc import DiscordRPC
|
||||||
|
from mpris import MprisManager
|
||||||
from music_db import MusicDB
|
from music_db import MusicDB
|
||||||
from player import AudioPlayer, SearchWorker, _best_thumbnail
|
from player import AudioPlayer, SearchWorker, _best_thumbnail
|
||||||
|
|
||||||
@@ -2279,6 +2280,9 @@ class TunettiWindow(QMainWindow):
|
|||||||
self.db = MusicDB(SETTINGS["db_path"])
|
self.db = MusicDB(SETTINGS["db_path"])
|
||||||
self.rpc = DiscordRPC(SETTINGS["discord_client_id"])
|
self.rpc = DiscordRPC(SETTINGS["discord_client_id"])
|
||||||
self.player = AudioPlayer(self)
|
self.player = AudioPlayer(self)
|
||||||
|
self.mpris = MprisManager()
|
||||||
|
self.mpris.start()
|
||||||
|
self._connect_mpris()
|
||||||
|
|
||||||
# ── Central widget ──
|
# ── Central widget ──
|
||||||
central = QWidget()
|
central = QWidget()
|
||||||
@@ -2947,6 +2951,54 @@ class TunettiWindow(QMainWindow):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("_poll_minimized error: %s", 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) ──
|
# ── Public accessors (for child dialogs) ──
|
||||||
|
|
||||||
def get_visualizer(self) -> AudioVisualizer:
|
def get_visualizer(self) -> AudioVisualizer:
|
||||||
@@ -2982,6 +3034,7 @@ class TunettiWindow(QMainWindow):
|
|||||||
def closeEvent(self, event) -> None:
|
def closeEvent(self, event) -> None:
|
||||||
# Flush any pending volume save before closing
|
# Flush any pending volume save before closing
|
||||||
self._playback.flush_volume()
|
self._playback.flush_volume()
|
||||||
|
self.mpris.stop()
|
||||||
self.player.shutdown()
|
self.player.shutdown()
|
||||||
self.rpc.stop()
|
self.rpc.stop()
|
||||||
self.db.close()
|
self.db.close()
|
||||||
|
|||||||
480
mpris.py
Normal file
480
mpris.py
Normal 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 D‑Bus thread.
|
||||||
|
|
||||||
|
When the ``dbus-fast`` library is unavailable or D‑Bus 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 D‑Bus 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):
|
||||||
|
"""Thread‑safe Qt signal bridge for MPRIS commands.
|
||||||
|
|
||||||
|
Created on the Qt main thread. Signals are emitted from the D‑Bus
|
||||||
|
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 D‑Bus 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 D‑Bus service.
|
||||||
|
|
||||||
|
Returns ``True`` if the service was started successfully,
|
||||||
|
``False`` if D‑Bus 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 D‑Bus 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 D‑Bus property changes.
|
||||||
|
|
||||||
|
*status* is one of ``"playing"``, ``"paused"``, ``"stopped"``.
|
||||||
|
This is thread-safe: the actual D‑Bus signal emission happens on
|
||||||
|
the D‑Bus 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 D‑Bus thread will see the new values).
|
||||||
|
self._state.update(mpris_status, metadata, can_prev)
|
||||||
|
|
||||||
|
# Queue the property-change notification for the D‑Bus 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 D‑Bus 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 D‑Bus 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 D‑Bus 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 D‑Bus 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 D‑Bus, 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()
|
||||||
@@ -2,3 +2,4 @@ yt-dlp>=2024.0.0
|
|||||||
ytmusicapi>=1.0.0
|
ytmusicapi>=1.0.0
|
||||||
pypresence>=4.0.0
|
pypresence>=4.0.0
|
||||||
PySide6>=6.5.0
|
PySide6>=6.5.0
|
||||||
|
dbus-fast>=5.0.0
|
||||||
|
|||||||
237
tests/test_mpris.py
Normal file
237
tests/test_mpris.py
Normal 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)
|
||||||
Reference in New Issue
Block a user